Merge branch 'chinwag-next'
|
|
@ -1,4 +1,3 @@
|
|||
import './public-path';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import Rails from '@rails/ujs';
|
||||
|
|
@ -273,7 +272,7 @@ async function mountReactComponent(element: Element) {
|
|||
);
|
||||
|
||||
const { default: Component } = (await import(
|
||||
`@/mastodon/components/admin/${componentName}`
|
||||
`@/mastodon/components/admin/${componentName}.jsx`
|
||||
)) as { default: React.ComponentType };
|
||||
|
||||
const root = createRoot(element);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
import './public-path';
|
||||
import { loadLocale } from 'mastodon/locales';
|
||||
import main from 'mastodon/main';
|
||||
|
||||
import { start } from '../mastodon/common';
|
||||
import { loadLocale } from '../mastodon/locales';
|
||||
import { loadPolyfills } from '../mastodon/polyfills';
|
||||
|
||||
start();
|
||||
import { loadPolyfills } from 'mastodon/polyfills';
|
||||
|
||||
loadPolyfills()
|
||||
.then(loadLocale)
|
||||
|
|
|
|||
3
app/javascript/entrypoints/common.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { start } from 'mastodon/common';
|
||||
|
||||
start();
|
||||
|
|
@ -1,15 +1,11 @@
|
|||
import './public-path';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { afterInitialRender } from 'mastodon/../hooks/useRenderSignal';
|
||||
import { afterInitialRender } from 'mastodon/hooks/useRenderSignal';
|
||||
|
||||
import { start } from '../mastodon/common';
|
||||
import { Status } from '../mastodon/features/standalone/status';
|
||||
import { loadPolyfills } from '../mastodon/polyfills';
|
||||
import ready from '../mastodon/ready';
|
||||
|
||||
start();
|
||||
|
||||
function loaded() {
|
||||
const mountNode = document.getElementById('mastodon-status');
|
||||
|
||||
|
|
@ -60,6 +56,10 @@ window.addEventListener('message', (e) => {
|
|||
|
||||
const data = e.data;
|
||||
|
||||
// Only set overflow to `hidden` once we got the expected `message` so the post can still be scrolled if
|
||||
// embedded without parent Javascript support
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// We use a timeout to allow for the React page to render before calculating the height
|
||||
afterInitialRender(() => {
|
||||
window.parent.postMessage(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import './public-path';
|
||||
import ready from '../mastodon/ready';
|
||||
|
||||
ready(() => {
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
/* Placeholder file to have `inert.scss` compiled by Webpack
|
||||
This is used by the `wicg-inert` polyfill */
|
||||
|
||||
import '../styles/inert.scss';
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import '../styles/mailer.scss';
|
||||
|
||||
require.context('../icons');
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
// Dynamically set webpack's loading path depending on a meta header, in order
|
||||
// to share the same assets regardless of instance configuration.
|
||||
// See https://webpack.js.org/guides/public-path/#on-the-fly
|
||||
|
||||
function removeOuterSlashes(string: string) {
|
||||
return string.replace(/^\/*/, '').replace(/\/*$/, '');
|
||||
}
|
||||
|
||||
function formatPublicPath(host = '', path = '') {
|
||||
let formattedHost = removeOuterSlashes(host);
|
||||
if (formattedHost && !/^http/i.test(formattedHost)) {
|
||||
formattedHost = `//${formattedHost}`;
|
||||
}
|
||||
const formattedPath = removeOuterSlashes(path);
|
||||
return `${formattedHost}/${formattedPath}/`;
|
||||
}
|
||||
|
||||
const cdnHost = document.querySelector<HTMLMetaElement>('meta[name=cdn-host]');
|
||||
|
||||
__webpack_public_path__ = formatPublicPath(
|
||||
cdnHost ? cdnHost.content : '',
|
||||
process.env.PUBLIC_OUTPUT_PATH,
|
||||
);
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import './public-path';
|
||||
|
||||
import { IntlMessageFormat } from 'intl-messageformat';
|
||||
import type { MessageDescriptor, PrimitiveType } from 'react-intl';
|
||||
import { defineMessages } from 'react-intl';
|
||||
|
|
@ -10,7 +8,6 @@ import Rails from '@rails/ujs';
|
|||
import axios from 'axios';
|
||||
import { throttle } from 'lodash';
|
||||
|
||||
import { start } from '../mastodon/common';
|
||||
import { timeAgoString } from '../mastodon/components/relative_timestamp';
|
||||
import emojify from '../mastodon/features/emoji/emoji';
|
||||
import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions';
|
||||
|
|
@ -20,8 +17,6 @@ import ready from '../mastodon/ready';
|
|||
|
||||
import 'cocoon-js-vanilla';
|
||||
|
||||
start();
|
||||
|
||||
const messages = defineMessages({
|
||||
usernameTaken: {
|
||||
id: 'username.taken',
|
||||
|
|
@ -68,7 +63,7 @@ function loaded() {
|
|||
|
||||
if (id) message = localeData[id];
|
||||
|
||||
if (!message) message = defaultMessage as string;
|
||||
message ??= defaultMessage as string;
|
||||
|
||||
const messageFormat = new IntlMessageFormat(message, locale);
|
||||
return messageFormat.format(values) as string;
|
||||
|
|
@ -119,7 +114,11 @@ function loaded() {
|
|||
formattedContent = dateFormat.format(datetime);
|
||||
}
|
||||
|
||||
content.title = formattedContent;
|
||||
const timeGiven = content.dateTime.includes('T');
|
||||
content.title = timeGiven
|
||||
? dateTimeFormat.format(datetime)
|
||||
: dateFormat.format(datetime);
|
||||
|
||||
content.textContent = formattedContent;
|
||||
});
|
||||
|
||||
|
|
@ -149,9 +148,7 @@ function loaded() {
|
|||
const reactComponents = document.querySelectorAll('[data-component]');
|
||||
|
||||
if (reactComponents.length > 0) {
|
||||
import(
|
||||
/* webpackChunkName: "containers/media_container" */ '../mastodon/containers/media_container'
|
||||
)
|
||||
import('../mastodon/containers/media_container')
|
||||
.then(({ default: MediaContainer }) => {
|
||||
reactComponents.forEach((component) => {
|
||||
Array.from(component.children).forEach((child) => {
|
||||
|
|
@ -230,62 +227,6 @@ function loaded() {
|
|||
}
|
||||
},
|
||||
);
|
||||
|
||||
Rails.delegate(
|
||||
document,
|
||||
'button.status__content__spoiler-link',
|
||||
'click',
|
||||
function () {
|
||||
if (!(this instanceof HTMLButtonElement)) return;
|
||||
|
||||
const statusEl = this.parentNode?.parentNode;
|
||||
|
||||
if (
|
||||
!(
|
||||
statusEl instanceof HTMLDivElement &&
|
||||
statusEl.classList.contains('.status__content')
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
if (statusEl.dataset.spoiler === 'expanded') {
|
||||
statusEl.dataset.spoiler = 'folded';
|
||||
this.textContent = new IntlMessageFormat(
|
||||
localeData['status.show_more'] ?? 'Show more',
|
||||
locale,
|
||||
).format() as string;
|
||||
} else {
|
||||
statusEl.dataset.spoiler = 'expanded';
|
||||
this.textContent = new IntlMessageFormat(
|
||||
localeData['status.show_less'] ?? 'Show less',
|
||||
locale,
|
||||
).format() as string;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
document
|
||||
.querySelectorAll<HTMLButtonElement>('button.status__content__spoiler-link')
|
||||
.forEach((spoilerLink) => {
|
||||
const statusEl = spoilerLink.parentNode?.parentNode;
|
||||
|
||||
if (
|
||||
!(
|
||||
statusEl instanceof HTMLDivElement &&
|
||||
statusEl.classList.contains('.status__content')
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
const message =
|
||||
statusEl.dataset.spoiler === 'expanded'
|
||||
? (localeData['status.show_less'] ?? 'Show less')
|
||||
: (localeData['status.show_more'] ?? 'Show more');
|
||||
spoilerLink.textContent = new IntlMessageFormat(
|
||||
message,
|
||||
locale,
|
||||
).format() as string;
|
||||
});
|
||||
}
|
||||
|
||||
Rails.delegate(
|
||||
|
|
@ -327,31 +268,24 @@ Rails.delegate(document, '.input-copy button', 'click', ({ target }) => {
|
|||
|
||||
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();
|
||||
|
||||
navigator.clipboard
|
||||
.writeText(input.value)
|
||||
.then(() => {
|
||||
const parent = target.parentElement;
|
||||
|
||||
if (!parent) return;
|
||||
parent.classList.add('copied');
|
||||
if (parent) {
|
||||
parent.classList.add('copied');
|
||||
|
||||
setTimeout(() => {
|
||||
parent.classList.remove('copied');
|
||||
}, 700);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
setTimeout(() => {
|
||||
parent.classList.remove('copied');
|
||||
}, 700);
|
||||
}
|
||||
|
||||
input.readOnly = oldReadOnly;
|
||||
return true;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error(error);
|
||||
});
|
||||
});
|
||||
|
||||
const toggleSidebar = () => {
|
||||
|
|
@ -446,6 +380,24 @@ Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => {
|
|||
});
|
||||
});
|
||||
|
||||
Rails.delegate(document, '.rules-list button', 'click', ({ target }) => {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const button = target.closest('button');
|
||||
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.ariaExpanded === 'true') {
|
||||
button.ariaExpanded = 'false';
|
||||
} else {
|
||||
button.ariaExpanded = 'true';
|
||||
}
|
||||
});
|
||||
|
||||
function main() {
|
||||
ready(loaded).catch((error: unknown) => {
|
||||
console.error(error);
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@ and performs no other task.
|
|||
|
||||
*/
|
||||
|
||||
import './public-path';
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
interface JRDLink {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
import './public-path';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { start } from '../mastodon/common';
|
||||
import ComposeContainer from '../mastodon/containers/compose_container';
|
||||
import { loadPolyfills } from '../mastodon/polyfills';
|
||||
import ready from '../mastodon/ready';
|
||||
|
||||
start();
|
||||
|
||||
function loaded() {
|
||||
const mountNode = document.getElementById('mastodon-compose');
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import './public-path';
|
||||
import axios from 'axios';
|
||||
|
||||
import ready from '../mastodon/ready';
|
||||
|
|
|
|||
BIN
app/javascript/images/archetypes/booster.png
Executable file
|
After Width: | Height: | Size: 554 KiB |
BIN
app/javascript/images/archetypes/lurker.png
Executable file
|
After Width: | Height: | Size: 858 KiB |
BIN
app/javascript/images/archetypes/oracle.png
Executable file
|
After Width: | Height: | Size: 985 KiB |
BIN
app/javascript/images/archetypes/pollster.png
Executable file
|
After Width: | Height: | Size: 636 KiB |
BIN
app/javascript/images/archetypes/replier.png
Executable file
|
After Width: | Height: | Size: 653 KiB |
|
|
@ -1,2 +1,2 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="79" height="79" viewBox="0 0 79 75"><symbol id="logo-symbol-icon"><path d="M74.7135 16.6043C73.6199 8.54587 66.5351 2.19527 58.1366 0.964691C56.7196 0.756754 51.351 0 38.9148 0H38.822C26.3824 0 23.7135 0.756754 22.2966 0.964691C14.1319 2.16118 6.67571 7.86752 4.86669 16.0214C3.99657 20.0369 3.90371 24.4888 4.06535 28.5726C4.29578 34.4289 4.34049 40.275 4.877 46.1075C5.24791 49.9817 5.89495 53.8251 6.81328 57.6088C8.53288 64.5968 15.4938 70.4122 22.3138 72.7848C29.6155 75.259 37.468 75.6697 44.9919 73.971C45.8196 73.7801 46.6381 73.5586 47.4475 73.3063C49.2737 72.7302 51.4164 72.086 52.9915 70.9542C53.0131 70.9384 53.0308 70.9178 53.0433 70.8942C53.0558 70.8706 53.0628 70.8445 53.0637 70.8179V65.1661C53.0634 65.1412 53.0574 65.1167 53.0462 65.0944C53.035 65.0721 53.0189 65.0525 52.9992 65.0371C52.9794 65.0218 52.9564 65.011 52.9318 65.0056C52.9073 65.0002 52.8819 65.0003 52.8574 65.0059C48.0369 66.1472 43.0971 66.7193 38.141 66.7103C29.6118 66.7103 27.3178 62.6981 26.6609 61.0278C26.1329 59.5842 25.7976 58.0784 25.6636 56.5486C25.6622 56.5229 25.667 56.4973 25.6775 56.4738C25.688 56.4502 25.7039 56.4295 25.724 56.4132C25.7441 56.397 25.7678 56.3856 25.7931 56.3801C25.8185 56.3746 25.8448 56.3751 25.8699 56.3816C30.6101 57.5151 35.4693 58.0873 40.3455 58.086C41.5183 58.086 42.6876 58.086 43.8604 58.0553C48.7647 57.919 53.9339 57.6701 58.7591 56.7361C58.8794 56.7123 58.9998 56.6918 59.103 56.6611C66.7139 55.2124 73.9569 50.665 74.6929 39.1501C74.7204 38.6967 74.7892 34.4016 74.7892 33.9312C74.7926 32.3325 75.3085 22.5901 74.7135 16.6043ZM62.9996 45.3371H54.9966V25.9069C54.9966 21.8163 53.277 19.7302 49.7793 19.7302C45.9343 19.7302 44.0083 22.1981 44.0083 27.0727V37.7082H36.0534V27.0727C36.0534 22.1981 34.124 19.7302 30.279 19.7302C26.8019 19.7302 25.0651 21.8163 25.0617 25.9069V45.3371H17.0656V25.3172C17.0656 21.2266 18.1191 17.9769 20.2262 15.568C22.3998 13.1648 25.2509 11.9308 28.7898 11.9308C32.8859 11.9308 35.9812 13.492 38.0447 16.6111L40.036 19.9245L42.0308 16.6111C44.0943 13.492 47.1896 11.9308 51.2788 11.9308C54.8143 11.9308 57.6654 13.1648 59.8459 15.568C61.9529 17.9746 63.0065 21.2243 63.0065 25.3172L62.9996 45.3371Z" fill="currentColor"/></symbol><use xlink:href="#logo-symbol-icon"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="79" height="79" viewBox="0 0 79 75"><symbol id="logo-symbol-icon"><path d="M63 45.3v-20c0-4.1-1-7.3-3.2-9.7-2.1-2.4-5-3.7-8.5-3.7-4.1 0-7.2 1.6-9.3 4.7l-2 3.3-2-3.3c-2-3.1-5.1-4.7-9.2-4.7-3.5 0-6.4 1.3-8.6 3.7-2.1 2.4-3.1 5.6-3.1 9.7v20h8V25.9c0-4.1 1.7-6.2 5.2-6.2 3.8 0 5.8 2.5 5.8 7.4V37.7H44V27.1c0-4.9 1.9-7.4 5.8-7.4 3.5 0 5.2 2.1 5.2 6.2V45.3h8ZM74.7 16.6c.6 6 .1 15.7.1 17.3 0 .5-.1 4.8-.1 5.3-.7 11.5-8 16-15.6 17.5-.1 0-.2 0-.3 0-4.9 1-10 1.2-14.9 1.4-1.2 0-2.4 0-3.6 0-4.8 0-9.7-.6-14.4-1.7-.1 0-.1 0-.1 0s-.1 0-.1 0 0 .1 0 .1 0 0 0 0c.1 1.6.4 3.1 1 4.5.6 1.7 2.9 5.7 11.4 5.7 5 0 9.9-.6 14.8-1.7 0 0 0 0 0 0 .1 0 .1 0 .1 0 0 .1 0 .1 0 .1.1 0 .1 0 .1.1v5.6s0 .1-.1.1c0 0 0 0 0 .1-1.6 1.1-3.7 1.7-5.6 2.3-.8.3-1.6.5-2.4.7-7.5 1.7-15.4 1.3-22.7-1.2-6.8-2.4-13.8-8.2-15.5-15.2-.9-3.8-1.6-7.6-1.9-11.5-.6-5.8-.6-11.7-.8-17.5C3.9 24.5 4 20 4.9 16 6.7 7.9 14.1 2.2 22.3 1c1.4-.2 4.1-1 16.5-1h.1C51.4 0 56.7.8 58.1 1c8.4 1.2 15.5 7.5 16.6 15.6Z" fill="currentColor"/></symbol><use xlink:href="#logo-symbol-icon"/></svg>
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg"><symbol id="mastodon-svg-logo" viewBox="0 0 216.4144 232.00976"><path d="M107.86523 0C78.203984.2425 49.672422 3.4535937 33.044922 11.089844c0 0-32.97656262 14.752031-32.97656262 65.082031 0 11.525-.224375 25.306175.140625 39.919925 1.19750002 49.22 9.02375002 97.72843 54.53124962 109.77343 20.9825 5.55375 38.99711 6.71547 53.505856 5.91797 26.31125-1.45875 41.08203-9.38867 41.08203-9.38867l-.86914-19.08984s-18.80171 5.92758-39.91796 5.20508c-20.921254-.7175-43.006879-2.25516-46.390629-27.94141-.3125-2.25625-.46875-4.66938-.46875-7.20313 0 0 20.536953 5.0204 46.564449 6.21289 15.915.73001 30.8393-.93343 45.99805-2.74218 29.07-3.47125 54.38125-21.3818 57.5625-37.74805 5.0125-25.78125 4.59961-62.916015 4.59961-62.916015 0-50.33-32.97461-65.082031-32.97461-65.082031C166.80539 3.4535938 138.255.2425 108.59375 0h-.72852zM74.296875 39.326172c12.355 0 21.710234 4.749297 27.896485 14.248047l6.01367 10.080078 6.01563-10.080078c6.185-9.49875 15.54023-14.248047 27.89648-14.248047 10.6775 0 19.28156 3.753672 25.85156 11.076172 6.36875 7.3225 9.53907 17.218828 9.53907 29.673828v60.941408h-24.14454V81.869141c0-12.46875-5.24453-18.798829-15.73828-18.798829-11.6025 0-17.41797 7.508516-17.41797 22.353516v32.375002H96.207031V85.423828c0-14.845-5.815468-22.353515-17.417969-22.353516-10.49375 0-15.740234 6.330079-15.740234 18.798829v59.148439H38.904297V80.076172c0-12.455 3.171016-22.351328 9.541015-29.673828 6.568751-7.3225 15.172813-11.076172 25.851563-11.076172z" /></symbol></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
3
app/javascript/images/quote.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="24" height="20" viewBox="0 0 24 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M23.933 2.82414C22.324 4.07931 21.0726 5.3569 20.1788 6.6569C19.3296 7.91207 18.905 9.1 18.905 10.2207C19.0838 10.131 19.3073 10.0862 19.5754 10.0862C19.8883 10.0414 20.1564 10.019 20.3799 10.019C21.4078 10.019 22.257 10.4448 22.9274 11.2966C23.6425 12.1034 24 13.1121 24 14.3224C24 15.8017 23.5084 17.0345 22.5251 18.0207C21.5419 19.0069 20.3129 19.5 18.838 19.5C17.2737 19.5 16.0447 18.9397 15.1508 17.819C14.257 16.6535 13.8101 15.1069 13.8101 13.1793C13.8101 10.8931 14.5028 8.62931 15.8883 6.38793C17.2737 4.14655 19.3073 2.01724 21.9888 0L23.933 2.82414ZM10.1229 2.82414C8.51397 4.07931 7.26257 5.3569 6.36872 6.6569C5.51955 7.91207 5.09497 9.1 5.09497 10.2207C5.27374 10.131 5.49721 10.0862 5.76536 10.0862C6.07821 10.0414 6.34637 10.019 6.56983 10.019C7.59777 10.019 8.44693 10.4448 9.11732 11.2966C9.8324 12.1034 10.1899 13.1121 10.1899 14.3224C10.1899 15.8017 9.69832 17.0345 8.71508 18.0207C7.73184 19.0069 6.50279 19.5 5.02793 19.5C3.46369 19.5 2.23464 18.9397 1.34078 17.819C0.446927 16.6535 0 15.1069 0 13.1793C0 10.8931 0.692738 8.62931 2.07821 6.38793C3.46369 4.14655 5.49721 2.01724 8.17877 0L10.1229 2.82414Z" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
|
@ -2,6 +2,7 @@ import { browserHistory } from 'mastodon/components/router';
|
|||
import { debounceWithDispatchAndArguments } from 'mastodon/utils/debounce';
|
||||
|
||||
import api, { getLinks } from '../api';
|
||||
import { me } from '../initial_state';
|
||||
|
||||
import {
|
||||
followAccountSuccess, unfollowAccountSuccess,
|
||||
|
|
@ -12,6 +13,7 @@ import {
|
|||
blockAccountSuccess, unblockAccountSuccess,
|
||||
pinAccountSuccess, unpinAccountSuccess,
|
||||
fetchRelationshipsSuccess,
|
||||
fetchEndorsedAccounts,
|
||||
} from './accounts_typed';
|
||||
import { importFetchedAccount, importFetchedAccounts } from './importer';
|
||||
|
||||
|
|
@ -142,6 +144,13 @@ export function fetchAccountFail(id, error) {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {Object} options
|
||||
* @param {boolean} [options.reblogs]
|
||||
* @param {boolean} [options.notify]
|
||||
* @returns {function(): void}
|
||||
*/
|
||||
export function followAccount(id, options = { reblogs: true }) {
|
||||
return (dispatch, getState) => {
|
||||
const alreadyFollowing = getState().getIn(['relationships', id, 'following']);
|
||||
|
|
@ -627,6 +636,7 @@ export function pinAccount(id) {
|
|||
|
||||
api().post(`/api/v1/accounts/${id}/pin`).then(response => {
|
||||
dispatch(pinAccountSuccess({ relationship: response.data }));
|
||||
dispatch(fetchEndorsedAccounts({ accountId: me }));
|
||||
}).catch(error => {
|
||||
dispatch(pinAccountFail(error));
|
||||
});
|
||||
|
|
@ -639,6 +649,7 @@ export function unpinAccount(id) {
|
|||
|
||||
api().post(`/api/v1/accounts/${id}/unpin`).then(response => {
|
||||
dispatch(unpinAccountSuccess({ relationship: response.data }));
|
||||
dispatch(fetchEndorsedAccounts({ accountId: me }));
|
||||
}).catch(error => {
|
||||
dispatch(unpinAccountFail(error));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||
|
||||
import { apiGetFamiliarFollowers } from '../api/accounts';
|
||||
|
||||
import { importFetchedAccounts } from './importer';
|
||||
|
||||
export const fetchAccountsFamiliarFollowers = createDataLoadingThunk(
|
||||
'accounts_familiar_followers/fetch',
|
||||
({ id }: { id: string }) => apiGetFamiliarFollowers(id),
|
||||
([data], { dispatch }) => {
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
dispatch(importFetchedAccounts(data.accounts));
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
accountIds: data.accounts.map((account) => account.id),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
|
@ -1,16 +1,18 @@
|
|||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
|
||||
import {
|
||||
apiRemoveAccountFromFollowers,
|
||||
apiGetEndorsedAccounts,
|
||||
} from 'mastodon/api/accounts';
|
||||
import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships';
|
||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||
|
||||
import { importFetchedAccounts } from './importer';
|
||||
|
||||
export const revealAccount = createAction<{
|
||||
id: string;
|
||||
}>('accounts/revealAccount');
|
||||
|
||||
export const importAccounts = createAction<{ accounts: ApiAccountJSON[] }>(
|
||||
'accounts/importAccounts',
|
||||
);
|
||||
|
||||
function actionWithSkipLoadingTrue<Args extends object>(args: Args) {
|
||||
return {
|
||||
payload: {
|
||||
|
|
@ -95,3 +97,19 @@ export const fetchRelationshipsSuccess = createAction(
|
|||
'relationships/fetch/SUCCESS',
|
||||
actionWithSkipLoadingTrue<{ relationships: ApiRelationshipJSON[] }>,
|
||||
);
|
||||
|
||||
export const removeAccountFromFollowers = createDataLoadingThunk(
|
||||
'accounts/remove_from_followers',
|
||||
({ accountId }: { accountId: string }) =>
|
||||
apiRemoveAccountFromFollowers(accountId),
|
||||
(relationship) => ({ relationship }),
|
||||
);
|
||||
|
||||
export const fetchEndorsedAccounts = createDataLoadingThunk(
|
||||
'accounts/endorsements',
|
||||
({ accountId }: { accountId: string }) => apiGetEndorsedAccounts(accountId),
|
||||
(data, { dispatch }) => {
|
||||
dispatch(importFetchedAccounts(data));
|
||||
return data;
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
import { defineMessages } from 'react-intl';
|
||||
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
const messages = defineMessages({
|
||||
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
|
||||
unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' },
|
||||
rateLimitedTitle: { id: 'alert.rate_limited.title', defaultMessage: 'Rate limited' },
|
||||
rateLimitedMessage: { id: 'alert.rate_limited.message', defaultMessage: 'Please retry after {retry_time, time, medium}.' },
|
||||
});
|
||||
|
||||
export const ALERT_SHOW = 'ALERT_SHOW';
|
||||
export const ALERT_DISMISS = 'ALERT_DISMISS';
|
||||
export const ALERT_CLEAR = 'ALERT_CLEAR';
|
||||
export const ALERT_NOOP = 'ALERT_NOOP';
|
||||
|
||||
export const dismissAlert = alert => ({
|
||||
type: ALERT_DISMISS,
|
||||
alert,
|
||||
});
|
||||
|
||||
export const clearAlert = () => ({
|
||||
type: ALERT_CLEAR,
|
||||
});
|
||||
|
||||
export const showAlert = alert => ({
|
||||
type: ALERT_SHOW,
|
||||
alert,
|
||||
});
|
||||
|
||||
export const showAlertForError = (error, skipNotFound = false) => {
|
||||
if (error.response) {
|
||||
const { data, status, statusText, headers } = error.response;
|
||||
|
||||
// Skip these errors as they are reflected in the UI
|
||||
if (skipNotFound && (status === 404 || status === 410)) {
|
||||
return { type: ALERT_NOOP };
|
||||
}
|
||||
|
||||
// Rate limit errors
|
||||
if (status === 429 && headers['x-ratelimit-reset']) {
|
||||
return showAlert({
|
||||
title: messages.rateLimitedTitle,
|
||||
message: messages.rateLimitedMessage,
|
||||
values: { 'retry_time': new Date(headers['x-ratelimit-reset']) },
|
||||
});
|
||||
}
|
||||
|
||||
return showAlert({
|
||||
title: `${status}`,
|
||||
message: data.error || statusText,
|
||||
});
|
||||
}
|
||||
|
||||
// An aborted request, e.g. due to reloading the browser window, it not really error
|
||||
if (error.code === AxiosError.ECONNABORTED) {
|
||||
return { type: ALERT_NOOP };
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
|
||||
return showAlert({
|
||||
title: messages.unexpectedTitle,
|
||||
message: messages.unexpectedMessage,
|
||||
});
|
||||
};
|
||||
76
app/javascript/mastodon/actions/alerts.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { defineMessages } from 'react-intl';
|
||||
|
||||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { AxiosError } from 'axios';
|
||||
import type { AxiosResponse } from 'axios';
|
||||
|
||||
import type { Alert } from 'mastodon/models/alert';
|
||||
|
||||
interface ApiErrorResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
|
||||
unexpectedMessage: {
|
||||
id: 'alert.unexpected.message',
|
||||
defaultMessage: 'An unexpected error occurred.',
|
||||
},
|
||||
rateLimitedTitle: {
|
||||
id: 'alert.rate_limited.title',
|
||||
defaultMessage: 'Rate limited',
|
||||
},
|
||||
rateLimitedMessage: {
|
||||
id: 'alert.rate_limited.message',
|
||||
defaultMessage: 'Please retry after {retry_time, time, medium}.',
|
||||
},
|
||||
});
|
||||
|
||||
export const dismissAlert = createAction<{ key: number }>('alerts/dismiss');
|
||||
|
||||
export const clearAlerts = createAction('alerts/clear');
|
||||
|
||||
export const showAlert = createAction<Omit<Alert, 'key'>>('alerts/show');
|
||||
|
||||
const ignoreAlert = createAction('alerts/ignore');
|
||||
|
||||
export const showAlertForError = (error: unknown, skipNotFound = false) => {
|
||||
if (error instanceof AxiosError && error.response) {
|
||||
const { status, statusText, headers } = error.response;
|
||||
const { data } = error.response as AxiosResponse<ApiErrorResponse>;
|
||||
|
||||
// Skip these errors as they are reflected in the UI
|
||||
if (skipNotFound && (status === 404 || status === 410)) {
|
||||
return ignoreAlert();
|
||||
}
|
||||
|
||||
// Rate limit errors
|
||||
if (status === 429 && headers['x-ratelimit-reset']) {
|
||||
return showAlert({
|
||||
title: messages.rateLimitedTitle,
|
||||
message: messages.rateLimitedMessage,
|
||||
values: {
|
||||
retry_time: new Date(headers['x-ratelimit-reset'] as string),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return showAlert({
|
||||
title: `${status}`,
|
||||
message: data.error ?? statusText,
|
||||
});
|
||||
}
|
||||
|
||||
// An aborted request, e.g. due to reloading the browser window, is not really an error
|
||||
if (error instanceof AxiosError && error.code === AxiosError.ECONNABORTED) {
|
||||
return ignoreAlert();
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
|
||||
return showAlert({
|
||||
title: messages.unexpectedTitle,
|
||||
message: messages.unexpectedMessage,
|
||||
});
|
||||
};
|
||||
|
|
@ -96,12 +96,17 @@ export const ensureComposeIsVisible = (getState) => {
|
|||
};
|
||||
|
||||
export function setComposeToStatus(status, text, spoiler_text) {
|
||||
return{
|
||||
type: COMPOSE_SET_STATUS,
|
||||
status,
|
||||
text,
|
||||
spoiler_text,
|
||||
};
|
||||
return (dispatch, getState) => {
|
||||
const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']);
|
||||
|
||||
dispatch({
|
||||
type: COMPOSE_SET_STATUS,
|
||||
status,
|
||||
text,
|
||||
spoiler_text,
|
||||
maxOptions,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function changeCompose(text) {
|
||||
|
|
@ -183,7 +188,7 @@ export function directCompose(account) {
|
|||
};
|
||||
}
|
||||
|
||||
export function submitCompose() {
|
||||
export function submitCompose(successCallback) {
|
||||
return function (dispatch, getState) {
|
||||
const status = getState().getIn(['compose', 'text'], '');
|
||||
const media = getState().getIn(['compose', 'media_attachments']);
|
||||
|
|
@ -239,6 +244,9 @@ export function submitCompose() {
|
|||
|
||||
dispatch(insertIntoTagHistory(response.data.tags, status));
|
||||
dispatch(submitComposeSuccess({ ...response.data }));
|
||||
if (typeof successCallback === 'function') {
|
||||
successCallback(response.data);
|
||||
}
|
||||
|
||||
// To make the app more responsive, immediately push the status
|
||||
// into the columns
|
||||
|
|
@ -414,7 +422,7 @@ export function initMediaEditModal(id) {
|
|||
|
||||
dispatch(openModal({
|
||||
modalType: 'FOCAL_POINT',
|
||||
modalProps: { id },
|
||||
modalProps: { mediaId: id },
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
|
|
|||
70
app/javascript/mastodon/actions/compose_typed.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import { apiUpdateMedia } from 'mastodon/api/compose';
|
||||
import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments';
|
||||
import type { MediaAttachment } from 'mastodon/models/media_attachment';
|
||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||
|
||||
type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & {
|
||||
unattached?: boolean;
|
||||
};
|
||||
|
||||
const simulateModifiedApiResponse = (
|
||||
media: MediaAttachment,
|
||||
params: { description?: string; focus?: string },
|
||||
): SimulatedMediaAttachmentJSON => {
|
||||
const [x, y] = (params.focus ?? '').split(',');
|
||||
|
||||
const data = {
|
||||
...media.toJS(),
|
||||
...params,
|
||||
meta: {
|
||||
focus: {
|
||||
x: parseFloat(x ?? '0'),
|
||||
y: parseFloat(y ?? '0'),
|
||||
},
|
||||
},
|
||||
} as unknown as SimulatedMediaAttachmentJSON;
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const changeUploadCompose = createDataLoadingThunk(
|
||||
'compose/changeUpload',
|
||||
async (
|
||||
{
|
||||
id,
|
||||
...params
|
||||
}: {
|
||||
id: string;
|
||||
description: string;
|
||||
focus: string;
|
||||
},
|
||||
{ getState },
|
||||
) => {
|
||||
const media = (
|
||||
(getState().compose as ImmutableMap<string, unknown>).get(
|
||||
'media_attachments',
|
||||
) as ImmutableList<MediaAttachment>
|
||||
).find((item) => item.get('id') === id);
|
||||
|
||||
// Editing already-attached media is deferred to editing the post itself.
|
||||
// For simplicity's sake, fake an API reply.
|
||||
if (media && !media.get('unattached')) {
|
||||
return new Promise<SimulatedMediaAttachmentJSON>((resolve) => {
|
||||
resolve(simulateModifiedApiResponse(media, params));
|
||||
});
|
||||
}
|
||||
|
||||
return apiUpdateMedia(id, params);
|
||||
},
|
||||
(media: SimulatedMediaAttachmentJSON) => {
|
||||
return {
|
||||
media,
|
||||
attached: typeof media.unattached !== 'undefined' && !media.unattached,
|
||||
};
|
||||
},
|
||||
{
|
||||
useLoadingBar: false,
|
||||
},
|
||||
);
|
||||
|
|
@ -12,14 +12,6 @@ export const DOMAIN_BLOCK_FAIL = 'DOMAIN_BLOCK_FAIL';
|
|||
export const DOMAIN_UNBLOCK_REQUEST = 'DOMAIN_UNBLOCK_REQUEST';
|
||||
export const DOMAIN_UNBLOCK_FAIL = 'DOMAIN_UNBLOCK_FAIL';
|
||||
|
||||
export const DOMAIN_BLOCKS_FETCH_REQUEST = 'DOMAIN_BLOCKS_FETCH_REQUEST';
|
||||
export const DOMAIN_BLOCKS_FETCH_SUCCESS = 'DOMAIN_BLOCKS_FETCH_SUCCESS';
|
||||
export const DOMAIN_BLOCKS_FETCH_FAIL = 'DOMAIN_BLOCKS_FETCH_FAIL';
|
||||
|
||||
export const DOMAIN_BLOCKS_EXPAND_REQUEST = 'DOMAIN_BLOCKS_EXPAND_REQUEST';
|
||||
export const DOMAIN_BLOCKS_EXPAND_SUCCESS = 'DOMAIN_BLOCKS_EXPAND_SUCCESS';
|
||||
export const DOMAIN_BLOCKS_EXPAND_FAIL = 'DOMAIN_BLOCKS_EXPAND_FAIL';
|
||||
|
||||
export function blockDomain(domain) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(blockDomainRequest(domain));
|
||||
|
|
@ -79,80 +71,6 @@ export function unblockDomainFail(domain, error) {
|
|||
};
|
||||
}
|
||||
|
||||
export function fetchDomainBlocks() {
|
||||
return (dispatch) => {
|
||||
dispatch(fetchDomainBlocksRequest());
|
||||
|
||||
api().get('/api/v1/domain_blocks').then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(fetchDomainBlocksSuccess(response.data, next ? next.uri : null));
|
||||
}).catch(err => {
|
||||
dispatch(fetchDomainBlocksFail(err));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchDomainBlocksRequest() {
|
||||
return {
|
||||
type: DOMAIN_BLOCKS_FETCH_REQUEST,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchDomainBlocksSuccess(domains, next) {
|
||||
return {
|
||||
type: DOMAIN_BLOCKS_FETCH_SUCCESS,
|
||||
domains,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchDomainBlocksFail(error) {
|
||||
return {
|
||||
type: DOMAIN_BLOCKS_FETCH_FAIL,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandDomainBlocks() {
|
||||
return (dispatch, getState) => {
|
||||
const url = getState().getIn(['domain_lists', 'blocks', 'next']);
|
||||
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandDomainBlocksRequest());
|
||||
|
||||
api().get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(expandDomainBlocksSuccess(response.data, next ? next.uri : null));
|
||||
}).catch(err => {
|
||||
dispatch(expandDomainBlocksFail(err));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function expandDomainBlocksRequest() {
|
||||
return {
|
||||
type: DOMAIN_BLOCKS_EXPAND_REQUEST,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandDomainBlocksSuccess(domains, next) {
|
||||
return {
|
||||
type: DOMAIN_BLOCKS_EXPAND_SUCCESS,
|
||||
domains,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandDomainBlocksFail(error) {
|
||||
return {
|
||||
type: DOMAIN_BLOCKS_EXPAND_FAIL,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export const initDomainBlockModal = account => dispatch => dispatch(openModal({
|
||||
modalType: 'DOMAIN_BLOCK',
|
||||
modalProps: {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
export const openDropdownMenu = createAction<{
|
||||
id: string;
|
||||
id: number;
|
||||
keyboard: boolean;
|
||||
scrollKey: string;
|
||||
scrollKey?: string;
|
||||
}>('dropdownMenu/open');
|
||||
|
||||
export const closeDropdownMenu = createAction<{ id: string }>(
|
||||
export const closeDropdownMenu = createAction<{ id: number }>(
|
||||
'dropdownMenu/close',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
import api from '../api';
|
||||
|
||||
export const FEATURED_TAGS_FETCH_REQUEST = 'FEATURED_TAGS_FETCH_REQUEST';
|
||||
export const FEATURED_TAGS_FETCH_SUCCESS = 'FEATURED_TAGS_FETCH_SUCCESS';
|
||||
export const FEATURED_TAGS_FETCH_FAIL = 'FEATURED_TAGS_FETCH_FAIL';
|
||||
|
||||
export const fetchFeaturedTags = (id) => (dispatch, getState) => {
|
||||
if (getState().getIn(['user_lists', 'featured_tags', id, 'items'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchFeaturedTagsRequest(id));
|
||||
|
||||
api().get(`/api/v1/accounts/${id}/featured_tags`)
|
||||
.then(({ data }) => dispatch(fetchFeaturedTagsSuccess(id, data)))
|
||||
.catch(err => dispatch(fetchFeaturedTagsFail(id, err)));
|
||||
};
|
||||
|
||||
export const fetchFeaturedTagsRequest = (id) => ({
|
||||
type: FEATURED_TAGS_FETCH_REQUEST,
|
||||
id,
|
||||
});
|
||||
|
||||
export const fetchFeaturedTagsSuccess = (id, tags) => ({
|
||||
type: FEATURED_TAGS_FETCH_SUCCESS,
|
||||
id,
|
||||
tags,
|
||||
});
|
||||
|
||||
export const fetchFeaturedTagsFail = (id, error) => ({
|
||||
type: FEATURED_TAGS_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
});
|
||||
7
app/javascript/mastodon/actions/featured_tags.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { apiGetFeaturedTags } from 'mastodon/api/accounts';
|
||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||
|
||||
export const fetchFeaturedTags = createDataLoadingThunk(
|
||||
'accounts/featured_tags',
|
||||
({ accountId }: { accountId: string }) => apiGetFeaturedTags(accountId),
|
||||
);
|
||||
7
app/javascript/mastodon/actions/importer/accounts.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
|
||||
|
||||
export const importAccounts = createAction<{ accounts: ApiAccountJSON[] }>(
|
||||
'accounts/importAccounts',
|
||||
);
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import { importAccounts } from '../accounts_typed';
|
||||
import { createPollFromServerJSON } from 'mastodon/models/poll';
|
||||
|
||||
import { normalizeStatus, normalizePoll } from './normalizer';
|
||||
import { importAccounts } from './accounts';
|
||||
import { normalizeStatus } from './normalizer';
|
||||
import { importPolls } from './polls';
|
||||
|
||||
export const STATUS_IMPORT = 'STATUS_IMPORT';
|
||||
export const STATUSES_IMPORT = 'STATUSES_IMPORT';
|
||||
export const POLLS_IMPORT = 'POLLS_IMPORT';
|
||||
export const FILTERS_IMPORT = 'FILTERS_IMPORT';
|
||||
|
||||
function pushUnique(array, object) {
|
||||
|
|
@ -25,10 +26,6 @@ export function importFilters(filters) {
|
|||
return { type: FILTERS_IMPORT, filters };
|
||||
}
|
||||
|
||||
export function importPolls(polls) {
|
||||
return { type: POLLS_IMPORT, polls };
|
||||
}
|
||||
|
||||
export function importFetchedAccount(account) {
|
||||
return importFetchedAccounts([account]);
|
||||
}
|
||||
|
|
@ -72,8 +69,12 @@ export function importFetchedStatuses(statuses) {
|
|||
processStatus(status.reblog);
|
||||
}
|
||||
|
||||
if (status.quote?.quoted_status) {
|
||||
processStatus(status.quote.quoted_status);
|
||||
}
|
||||
|
||||
if (status.poll?.id) {
|
||||
pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id])));
|
||||
pushUnique(polls, createPollFromServerJSON(status.poll, getState().polls[status.poll.id]));
|
||||
}
|
||||
|
||||
if (status.card) {
|
||||
|
|
@ -83,15 +84,9 @@ export function importFetchedStatuses(statuses) {
|
|||
|
||||
statuses.forEach(processStatus);
|
||||
|
||||
dispatch(importPolls(polls));
|
||||
dispatch(importPolls({ polls }));
|
||||
dispatch(importFetchedAccounts(accounts));
|
||||
dispatch(importStatuses(normalStatuses));
|
||||
dispatch(importFilters(filters));
|
||||
};
|
||||
}
|
||||
|
||||
export function importFetchedPoll(poll) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(importPolls([normalizePoll(poll, getState().getIn(['polls', poll.id]))]));
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
import escapeTextContentForBrowser from 'escape-html';
|
||||
|
||||
import { makeEmojiMap } from 'mastodon/models/custom_emoji';
|
||||
|
||||
import emojify from '../../features/emoji/emoji';
|
||||
import { expandSpoilers } from '../../initial_state';
|
||||
|
||||
const domParser = new DOMParser();
|
||||
|
||||
const makeEmojiMap = emojis => emojis.reduce((obj, emoji) => {
|
||||
obj[`:${emoji.shortcode}:`] = emoji;
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
export function searchTextFromRawStatus (status) {
|
||||
const spoilerText = status.spoiler_text || '';
|
||||
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
|
||||
|
|
@ -24,14 +21,31 @@ export function normalizeFilterResult(result) {
|
|||
return normalResult;
|
||||
}
|
||||
|
||||
function stripQuoteFallback(text) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = text;
|
||||
|
||||
wrapper.querySelector('.quote-inline')?.remove();
|
||||
|
||||
return wrapper.innerHTML;
|
||||
}
|
||||
|
||||
export function normalizeStatus(status, normalOldStatus) {
|
||||
const normalStatus = { ...status };
|
||||
|
||||
normalStatus.account = status.account.id;
|
||||
|
||||
if (status.reblog && status.reblog.id) {
|
||||
normalStatus.reblog = status.reblog.id;
|
||||
}
|
||||
|
||||
if (status.quote?.quoted_status ?? status.quote?.quoted_status_id) {
|
||||
normalStatus.quote = {
|
||||
...status.quote,
|
||||
quoted_status: status.quote.quoted_status?.id ?? status.quote?.quoted_status_id,
|
||||
};
|
||||
}
|
||||
|
||||
if (status.poll && status.poll.id) {
|
||||
normalStatus.poll = status.poll.id;
|
||||
}
|
||||
|
|
@ -81,6 +95,11 @@ export function normalizeStatus(status, normalOldStatus) {
|
|||
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
|
||||
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;
|
||||
|
||||
// Remove quote fallback link from the DOM so it doesn't mess with paragraph margins
|
||||
if (normalStatus.quote) {
|
||||
normalStatus.contentHtml = stripQuoteFallback(normalStatus.contentHtml);
|
||||
}
|
||||
|
||||
if (normalStatus.url && !(normalStatus.url.startsWith('http://') || normalStatus.url.startsWith('https://'))) {
|
||||
normalStatus.url = null;
|
||||
}
|
||||
|
|
@ -120,37 +139,10 @@ export function normalizeStatusTranslation(translation, status) {
|
|||
spoiler_text: translation.spoiler_text,
|
||||
};
|
||||
|
||||
return normalTranslation;
|
||||
}
|
||||
|
||||
export function normalizePoll(poll, normalOldPoll) {
|
||||
const normalPoll = { ...poll };
|
||||
const emojiMap = makeEmojiMap(poll.emojis);
|
||||
|
||||
normalPoll.options = poll.options.map((option, index) => {
|
||||
const normalOption = {
|
||||
...option,
|
||||
voted: poll.own_votes && poll.own_votes.includes(index),
|
||||
titleHtml: emojify(escapeTextContentForBrowser(option.title), emojiMap),
|
||||
};
|
||||
|
||||
if (normalOldPoll && normalOldPoll.getIn(['options', index, 'title']) === option.title) {
|
||||
normalOption.translation = normalOldPoll.getIn(['options', index, 'translation']);
|
||||
}
|
||||
|
||||
return normalOption;
|
||||
});
|
||||
|
||||
return normalPoll;
|
||||
}
|
||||
|
||||
export function normalizePollOptionTranslation(translation, poll) {
|
||||
const emojiMap = makeEmojiMap(poll.get('emojis').toJS());
|
||||
|
||||
const normalTranslation = {
|
||||
...translation,
|
||||
titleHtml: emojify(escapeTextContentForBrowser(translation.title), emojiMap),
|
||||
};
|
||||
// Remove quote fallback link from the DOM so it doesn't mess with paragraph margins
|
||||
if (status.get('quote')) {
|
||||
normalTranslation.contentHtml = stripQuoteFallback(normalTranslation.contentHtml);
|
||||
}
|
||||
|
||||
return normalTranslation;
|
||||
}
|
||||
|
|
|
|||
7
app/javascript/mastodon/actions/importer/polls.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import type { Poll } from 'mastodon/models/poll';
|
||||
|
||||
export const importPolls = createAction<{ polls: Poll[] }>(
|
||||
'poll/importMultiple',
|
||||
);
|
||||
|
|
@ -1,54 +1,14 @@
|
|||
import api from '../api';
|
||||
|
||||
import { showAlertForError } from './alerts';
|
||||
import { importFetchedAccounts } from './importer';
|
||||
|
||||
export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST';
|
||||
export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS';
|
||||
export const LIST_FETCH_FAIL = 'LIST_FETCH_FAIL';
|
||||
|
||||
export const LISTS_FETCH_REQUEST = 'LISTS_FETCH_REQUEST';
|
||||
export const LISTS_FETCH_SUCCESS = 'LISTS_FETCH_SUCCESS';
|
||||
export const LISTS_FETCH_FAIL = 'LISTS_FETCH_FAIL';
|
||||
|
||||
export const LIST_EDITOR_TITLE_CHANGE = 'LIST_EDITOR_TITLE_CHANGE';
|
||||
export const LIST_EDITOR_RESET = 'LIST_EDITOR_RESET';
|
||||
export const LIST_EDITOR_SETUP = 'LIST_EDITOR_SETUP';
|
||||
|
||||
export const LIST_CREATE_REQUEST = 'LIST_CREATE_REQUEST';
|
||||
export const LIST_CREATE_SUCCESS = 'LIST_CREATE_SUCCESS';
|
||||
export const LIST_CREATE_FAIL = 'LIST_CREATE_FAIL';
|
||||
|
||||
export const LIST_UPDATE_REQUEST = 'LIST_UPDATE_REQUEST';
|
||||
export const LIST_UPDATE_SUCCESS = 'LIST_UPDATE_SUCCESS';
|
||||
export const LIST_UPDATE_FAIL = 'LIST_UPDATE_FAIL';
|
||||
|
||||
export const LIST_DELETE_REQUEST = 'LIST_DELETE_REQUEST';
|
||||
export const LIST_DELETE_SUCCESS = 'LIST_DELETE_SUCCESS';
|
||||
export const LIST_DELETE_FAIL = 'LIST_DELETE_FAIL';
|
||||
|
||||
export const LIST_ACCOUNTS_FETCH_REQUEST = 'LIST_ACCOUNTS_FETCH_REQUEST';
|
||||
export const LIST_ACCOUNTS_FETCH_SUCCESS = 'LIST_ACCOUNTS_FETCH_SUCCESS';
|
||||
export const LIST_ACCOUNTS_FETCH_FAIL = 'LIST_ACCOUNTS_FETCH_FAIL';
|
||||
|
||||
export const LIST_EDITOR_SUGGESTIONS_CHANGE = 'LIST_EDITOR_SUGGESTIONS_CHANGE';
|
||||
export const LIST_EDITOR_SUGGESTIONS_READY = 'LIST_EDITOR_SUGGESTIONS_READY';
|
||||
export const LIST_EDITOR_SUGGESTIONS_CLEAR = 'LIST_EDITOR_SUGGESTIONS_CLEAR';
|
||||
|
||||
export const LIST_EDITOR_ADD_REQUEST = 'LIST_EDITOR_ADD_REQUEST';
|
||||
export const LIST_EDITOR_ADD_SUCCESS = 'LIST_EDITOR_ADD_SUCCESS';
|
||||
export const LIST_EDITOR_ADD_FAIL = 'LIST_EDITOR_ADD_FAIL';
|
||||
|
||||
export const LIST_EDITOR_REMOVE_REQUEST = 'LIST_EDITOR_REMOVE_REQUEST';
|
||||
export const LIST_EDITOR_REMOVE_SUCCESS = 'LIST_EDITOR_REMOVE_SUCCESS';
|
||||
export const LIST_EDITOR_REMOVE_FAIL = 'LIST_EDITOR_REMOVE_FAIL';
|
||||
|
||||
export const LIST_ADDER_RESET = 'LIST_ADDER_RESET';
|
||||
export const LIST_ADDER_SETUP = 'LIST_ADDER_SETUP';
|
||||
|
||||
export const LIST_ADDER_LISTS_FETCH_REQUEST = 'LIST_ADDER_LISTS_FETCH_REQUEST';
|
||||
export const LIST_ADDER_LISTS_FETCH_SUCCESS = 'LIST_ADDER_LISTS_FETCH_SUCCESS';
|
||||
export const LIST_ADDER_LISTS_FETCH_FAIL = 'LIST_ADDER_LISTS_FETCH_FAIL';
|
||||
export * from './lists_typed';
|
||||
|
||||
export const fetchList = id => (dispatch, getState) => {
|
||||
if (getState().getIn(['lists', id])) {
|
||||
|
|
@ -78,111 +38,6 @@ export const fetchListFail = (id, error) => ({
|
|||
error,
|
||||
});
|
||||
|
||||
export const fetchLists = () => (dispatch) => {
|
||||
dispatch(fetchListsRequest());
|
||||
|
||||
api().get('/api/v1/lists')
|
||||
.then(({ data }) => dispatch(fetchListsSuccess(data)))
|
||||
.catch(err => dispatch(fetchListsFail(err)));
|
||||
};
|
||||
|
||||
export const fetchListsRequest = () => ({
|
||||
type: LISTS_FETCH_REQUEST,
|
||||
});
|
||||
|
||||
export const fetchListsSuccess = lists => ({
|
||||
type: LISTS_FETCH_SUCCESS,
|
||||
lists,
|
||||
});
|
||||
|
||||
export const fetchListsFail = error => ({
|
||||
type: LISTS_FETCH_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
export const submitListEditor = shouldReset => (dispatch, getState) => {
|
||||
const listId = getState().getIn(['listEditor', 'listId']);
|
||||
const title = getState().getIn(['listEditor', 'title']);
|
||||
|
||||
if (listId === null) {
|
||||
dispatch(createList(title, shouldReset));
|
||||
} else {
|
||||
dispatch(updateList(listId, title, shouldReset));
|
||||
}
|
||||
};
|
||||
|
||||
export const setupListEditor = listId => (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: LIST_EDITOR_SETUP,
|
||||
list: getState().getIn(['lists', listId]),
|
||||
});
|
||||
|
||||
dispatch(fetchListAccounts(listId));
|
||||
};
|
||||
|
||||
export const changeListEditorTitle = value => ({
|
||||
type: LIST_EDITOR_TITLE_CHANGE,
|
||||
value,
|
||||
});
|
||||
|
||||
export const createList = (title, shouldReset) => (dispatch) => {
|
||||
dispatch(createListRequest());
|
||||
|
||||
api().post('/api/v1/lists', { title }).then(({ data }) => {
|
||||
dispatch(createListSuccess(data));
|
||||
|
||||
if (shouldReset) {
|
||||
dispatch(resetListEditor());
|
||||
}
|
||||
}).catch(err => dispatch(createListFail(err)));
|
||||
};
|
||||
|
||||
export const createListRequest = () => ({
|
||||
type: LIST_CREATE_REQUEST,
|
||||
});
|
||||
|
||||
export const createListSuccess = list => ({
|
||||
type: LIST_CREATE_SUCCESS,
|
||||
list,
|
||||
});
|
||||
|
||||
export const createListFail = error => ({
|
||||
type: LIST_CREATE_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
export const updateList = (id, title, shouldReset, isExclusive, replies_policy) => (dispatch) => {
|
||||
dispatch(updateListRequest(id));
|
||||
|
||||
api().put(`/api/v1/lists/${id}`, { title, replies_policy, exclusive: typeof isExclusive === 'undefined' ? undefined : !!isExclusive }).then(({ data }) => {
|
||||
dispatch(updateListSuccess(data));
|
||||
|
||||
if (shouldReset) {
|
||||
dispatch(resetListEditor());
|
||||
}
|
||||
}).catch(err => dispatch(updateListFail(id, err)));
|
||||
};
|
||||
|
||||
export const updateListRequest = id => ({
|
||||
type: LIST_UPDATE_REQUEST,
|
||||
id,
|
||||
});
|
||||
|
||||
export const updateListSuccess = list => ({
|
||||
type: LIST_UPDATE_SUCCESS,
|
||||
list,
|
||||
});
|
||||
|
||||
export const updateListFail = (id, error) => ({
|
||||
type: LIST_UPDATE_FAIL,
|
||||
id,
|
||||
error,
|
||||
});
|
||||
|
||||
export const resetListEditor = () => ({
|
||||
type: LIST_EDITOR_RESET,
|
||||
});
|
||||
|
||||
export const deleteList = id => (dispatch) => {
|
||||
dispatch(deleteListRequest(id));
|
||||
|
||||
|
|
@ -206,167 +61,3 @@ export const deleteListFail = (id, error) => ({
|
|||
id,
|
||||
error,
|
||||
});
|
||||
|
||||
export const fetchListAccounts = listId => (dispatch) => {
|
||||
dispatch(fetchListAccountsRequest(listId));
|
||||
|
||||
api().get(`/api/v1/lists/${listId}/accounts`, { params: { limit: 0 } }).then(({ data }) => {
|
||||
dispatch(importFetchedAccounts(data));
|
||||
dispatch(fetchListAccountsSuccess(listId, data));
|
||||
}).catch(err => dispatch(fetchListAccountsFail(listId, err)));
|
||||
};
|
||||
|
||||
export const fetchListAccountsRequest = id => ({
|
||||
type: LIST_ACCOUNTS_FETCH_REQUEST,
|
||||
id,
|
||||
});
|
||||
|
||||
export const fetchListAccountsSuccess = (id, accounts, next) => ({
|
||||
type: LIST_ACCOUNTS_FETCH_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
next,
|
||||
});
|
||||
|
||||
export const fetchListAccountsFail = (id, error) => ({
|
||||
type: LIST_ACCOUNTS_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
});
|
||||
|
||||
export const fetchListSuggestions = q => (dispatch) => {
|
||||
const params = {
|
||||
q,
|
||||
resolve: false,
|
||||
limit: 4,
|
||||
following: true,
|
||||
};
|
||||
|
||||
api().get('/api/v1/accounts/search', { params }).then(({ data }) => {
|
||||
dispatch(importFetchedAccounts(data));
|
||||
dispatch(fetchListSuggestionsReady(q, data));
|
||||
}).catch(error => dispatch(showAlertForError(error)));
|
||||
};
|
||||
|
||||
export const fetchListSuggestionsReady = (query, accounts) => ({
|
||||
type: LIST_EDITOR_SUGGESTIONS_READY,
|
||||
query,
|
||||
accounts,
|
||||
});
|
||||
|
||||
export const clearListSuggestions = () => ({
|
||||
type: LIST_EDITOR_SUGGESTIONS_CLEAR,
|
||||
});
|
||||
|
||||
export const changeListSuggestions = value => ({
|
||||
type: LIST_EDITOR_SUGGESTIONS_CHANGE,
|
||||
value,
|
||||
});
|
||||
|
||||
export const addToListEditor = accountId => (dispatch, getState) => {
|
||||
dispatch(addToList(getState().getIn(['listEditor', 'listId']), accountId));
|
||||
};
|
||||
|
||||
export const addToList = (listId, accountId) => (dispatch) => {
|
||||
dispatch(addToListRequest(listId, accountId));
|
||||
|
||||
api().post(`/api/v1/lists/${listId}/accounts`, { account_ids: [accountId] })
|
||||
.then(() => dispatch(addToListSuccess(listId, accountId)))
|
||||
.catch(err => dispatch(addToListFail(listId, accountId, err)));
|
||||
};
|
||||
|
||||
export const addToListRequest = (listId, accountId) => ({
|
||||
type: LIST_EDITOR_ADD_REQUEST,
|
||||
listId,
|
||||
accountId,
|
||||
});
|
||||
|
||||
export const addToListSuccess = (listId, accountId) => ({
|
||||
type: LIST_EDITOR_ADD_SUCCESS,
|
||||
listId,
|
||||
accountId,
|
||||
});
|
||||
|
||||
export const addToListFail = (listId, accountId, error) => ({
|
||||
type: LIST_EDITOR_ADD_FAIL,
|
||||
listId,
|
||||
accountId,
|
||||
error,
|
||||
});
|
||||
|
||||
export const removeFromListEditor = accountId => (dispatch, getState) => {
|
||||
dispatch(removeFromList(getState().getIn(['listEditor', 'listId']), accountId));
|
||||
};
|
||||
|
||||
export const removeFromList = (listId, accountId) => (dispatch) => {
|
||||
dispatch(removeFromListRequest(listId, accountId));
|
||||
|
||||
api().delete(`/api/v1/lists/${listId}/accounts`, { params: { account_ids: [accountId] } })
|
||||
.then(() => dispatch(removeFromListSuccess(listId, accountId)))
|
||||
.catch(err => dispatch(removeFromListFail(listId, accountId, err)));
|
||||
};
|
||||
|
||||
export const removeFromListRequest = (listId, accountId) => ({
|
||||
type: LIST_EDITOR_REMOVE_REQUEST,
|
||||
listId,
|
||||
accountId,
|
||||
});
|
||||
|
||||
export const removeFromListSuccess = (listId, accountId) => ({
|
||||
type: LIST_EDITOR_REMOVE_SUCCESS,
|
||||
listId,
|
||||
accountId,
|
||||
});
|
||||
|
||||
export const removeFromListFail = (listId, accountId, error) => ({
|
||||
type: LIST_EDITOR_REMOVE_FAIL,
|
||||
listId,
|
||||
accountId,
|
||||
error,
|
||||
});
|
||||
|
||||
export const resetListAdder = () => ({
|
||||
type: LIST_ADDER_RESET,
|
||||
});
|
||||
|
||||
export const setupListAdder = accountId => (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: LIST_ADDER_SETUP,
|
||||
account: getState().getIn(['accounts', accountId]),
|
||||
});
|
||||
dispatch(fetchLists());
|
||||
dispatch(fetchAccountLists(accountId));
|
||||
};
|
||||
|
||||
export const fetchAccountLists = accountId => (dispatch) => {
|
||||
dispatch(fetchAccountListsRequest(accountId));
|
||||
|
||||
api().get(`/api/v1/accounts/${accountId}/lists`)
|
||||
.then(({ data }) => dispatch(fetchAccountListsSuccess(accountId, data)))
|
||||
.catch(err => dispatch(fetchAccountListsFail(accountId, err)));
|
||||
};
|
||||
|
||||
export const fetchAccountListsRequest = id => ({
|
||||
type:LIST_ADDER_LISTS_FETCH_REQUEST,
|
||||
id,
|
||||
});
|
||||
|
||||
export const fetchAccountListsSuccess = (id, lists) => ({
|
||||
type: LIST_ADDER_LISTS_FETCH_SUCCESS,
|
||||
id,
|
||||
lists,
|
||||
});
|
||||
|
||||
export const fetchAccountListsFail = (id, err) => ({
|
||||
type: LIST_ADDER_LISTS_FETCH_FAIL,
|
||||
id,
|
||||
err,
|
||||
});
|
||||
|
||||
export const addToListAdder = listId => (dispatch, getState) => {
|
||||
dispatch(addToList(listId, getState().getIn(['listAdder', 'accountId'])));
|
||||
};
|
||||
|
||||
export const removeFromListAdder = listId => (dispatch, getState) => {
|
||||
dispatch(removeFromList(listId, getState().getIn(['listAdder', 'accountId'])));
|
||||
};
|
||||
|
|
|
|||
17
app/javascript/mastodon/actions/lists_typed.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { apiCreate, apiUpdate, apiGetLists } from 'mastodon/api/lists';
|
||||
import type { List } from 'mastodon/models/list';
|
||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||
|
||||
export const createList = createDataLoadingThunk(
|
||||
'list/create',
|
||||
(list: Partial<List>) => apiCreate(list),
|
||||
);
|
||||
|
||||
export const updateList = createDataLoadingThunk(
|
||||
'list/update',
|
||||
(list: Partial<List>) => apiUpdate(list),
|
||||
);
|
||||
|
||||
export const fetchLists = createDataLoadingThunk('lists/fetch', () =>
|
||||
apiGetLists(),
|
||||
);
|
||||
|
|
@ -9,6 +9,7 @@ export type ModalType = keyof typeof MODAL_COMPONENTS;
|
|||
interface OpenModalPayload {
|
||||
modalType: ModalType;
|
||||
modalProps: ModalProps;
|
||||
previousModalProps?: ModalProps;
|
||||
}
|
||||
export const openModal = createAction<OpenModalPayload>('MODAL_OPEN');
|
||||
|
||||
|
|
|
|||
7
app/javascript/mastodon/actions/navigation.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
export const openNavigation = createAction('navigation/open');
|
||||
|
||||
export const closeNavigation = createAction('navigation/close');
|
||||
|
||||
export const toggleNavigation = createAction('navigation/toggle');
|
||||
|
|
@ -1,69 +1,31 @@
|
|||
import { IntlMessageFormat } from 'intl-messageformat';
|
||||
import { defineMessages } from 'react-intl';
|
||||
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
|
||||
import { compareId } from 'mastodon/compare_id';
|
||||
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
|
||||
|
||||
import api, { getLinks } from '../api';
|
||||
import { unescapeHTML } from '../utils/html';
|
||||
import { requestNotificationPermission } from '../utils/notifications';
|
||||
|
||||
import { fetchFollowRequests } from './accounts';
|
||||
import {
|
||||
importFetchedAccount,
|
||||
importFetchedAccounts,
|
||||
importFetchedStatus,
|
||||
importFetchedStatuses,
|
||||
} from './importer';
|
||||
import { submitMarkers } from './markers';
|
||||
import { notificationsUpdate } from "./notifications_typed";
|
||||
import { register as registerPushNotifications } from './push_notifications';
|
||||
import { saveSettings } from './settings';
|
||||
|
||||
export * from "./notifications_typed";
|
||||
|
||||
export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
|
||||
|
||||
export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
|
||||
export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS';
|
||||
export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL';
|
||||
|
||||
export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET';
|
||||
|
||||
export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
|
||||
export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING';
|
||||
|
||||
export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT';
|
||||
export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
|
||||
|
||||
export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ';
|
||||
|
||||
export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT';
|
||||
export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION';
|
||||
|
||||
export const NOTIFICATION_REQUESTS_ACCEPT_REQUEST = 'NOTIFICATION_REQUESTS_ACCEPT_REQUEST';
|
||||
export const NOTIFICATION_REQUESTS_ACCEPT_SUCCESS = 'NOTIFICATION_REQUESTS_ACCEPT_SUCCESS';
|
||||
export const NOTIFICATION_REQUESTS_ACCEPT_FAIL = 'NOTIFICATION_REQUESTS_ACCEPT_FAIL';
|
||||
|
||||
export const NOTIFICATION_REQUESTS_DISMISS_REQUEST = 'NOTIFICATION_REQUESTS_DISMISS_REQUEST';
|
||||
export const NOTIFICATION_REQUESTS_DISMISS_SUCCESS = 'NOTIFICATION_REQUESTS_DISMISS_SUCCESS';
|
||||
export const NOTIFICATION_REQUESTS_DISMISS_FAIL = 'NOTIFICATION_REQUESTS_DISMISS_FAIL';
|
||||
|
||||
defineMessages({
|
||||
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
|
||||
group: { id: 'notifications.group', defaultMessage: '{count} notifications' },
|
||||
});
|
||||
|
||||
export const loadPending = () => ({
|
||||
type: NOTIFICATIONS_LOAD_PENDING,
|
||||
});
|
||||
|
||||
export function updateNotifications(notification, intlMessages, intlLocale) {
|
||||
return (dispatch, getState) => {
|
||||
const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']);
|
||||
const showInColumn = activeFilter === 'all' ? getState().getIn(['settings', 'notifications', 'shows', notification.type], true) : activeFilter === notification.type;
|
||||
const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
|
||||
const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
|
||||
|
||||
|
|
@ -85,25 +47,9 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
|
|||
|
||||
dispatch(submitMarkers());
|
||||
|
||||
if (showInColumn) {
|
||||
dispatch(importFetchedAccount(notification.account));
|
||||
|
||||
if (notification.status) {
|
||||
dispatch(importFetchedStatus(notification.status));
|
||||
}
|
||||
|
||||
if (notification.report) {
|
||||
dispatch(importFetchedAccount(notification.report.target_account));
|
||||
}
|
||||
|
||||
|
||||
dispatch(notificationsUpdate({ notification, preferPendingItems, playSound: playSound && !filtered}));
|
||||
} else if (playSound && !filtered) {
|
||||
dispatch({
|
||||
type: NOTIFICATIONS_UPDATE_NOOP,
|
||||
meta: { sound: 'boop' },
|
||||
});
|
||||
}
|
||||
// `notificationsUpdate` is still used in `user_lists` and `relationships` reducers
|
||||
dispatch(importFetchedAccount(notification.account));
|
||||
dispatch(notificationsUpdate({ notification, playSound: playSound && !filtered}));
|
||||
|
||||
// Desktop notifications
|
||||
if (typeof window.Notification !== 'undefined' && showAlert && !filtered) {
|
||||
|
|
@ -120,141 +66,8 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
|
|||
};
|
||||
}
|
||||
|
||||
const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
|
||||
|
||||
const excludeTypesFromFilter = filter => {
|
||||
const allTypes = ImmutableList([
|
||||
'follow',
|
||||
'follow_request',
|
||||
'favourite',
|
||||
'reblog',
|
||||
'mention',
|
||||
'poll',
|
||||
'status',
|
||||
'update',
|
||||
'admin.sign_up',
|
||||
'admin.report',
|
||||
]);
|
||||
|
||||
return allTypes.filterNot(item => item === filter).toJS();
|
||||
};
|
||||
|
||||
const noOp = () => {};
|
||||
|
||||
let expandNotificationsController = new AbortController();
|
||||
|
||||
export function expandNotifications({ maxId = undefined, forceLoad = false }) {
|
||||
return async (dispatch, getState) => {
|
||||
const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']);
|
||||
const notifications = getState().get('notifications');
|
||||
const isLoadingMore = !!maxId;
|
||||
|
||||
if (notifications.get('isLoading')) {
|
||||
if (forceLoad) {
|
||||
expandNotificationsController.abort();
|
||||
expandNotificationsController = new AbortController();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const params = {
|
||||
max_id: maxId,
|
||||
exclude_types: activeFilter === 'all'
|
||||
? excludeTypesFromSettings(getState())
|
||||
: excludeTypesFromFilter(activeFilter),
|
||||
};
|
||||
|
||||
if (!params.max_id && (notifications.get('items', ImmutableList()).size + notifications.get('pendingItems', ImmutableList()).size) > 0) {
|
||||
const a = notifications.getIn(['pendingItems', 0, 'id']);
|
||||
const b = notifications.getIn(['items', 0, 'id']);
|
||||
|
||||
if (a && b && compareId(a, b) > 0) {
|
||||
params.since_id = a;
|
||||
} else {
|
||||
params.since_id = b || a;
|
||||
}
|
||||
}
|
||||
|
||||
const isLoadingRecent = !!params.since_id;
|
||||
|
||||
dispatch(expandNotificationsRequest(isLoadingMore));
|
||||
|
||||
try {
|
||||
const response = await api().get('/api/v1/notifications', { params, signal: expandNotificationsController.signal });
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
|
||||
dispatch(importFetchedAccounts(response.data.map(item => item.account)));
|
||||
dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
|
||||
dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account)));
|
||||
|
||||
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
|
||||
dispatch(submitMarkers());
|
||||
} catch(error) {
|
||||
dispatch(expandNotificationsFail(error, isLoadingMore));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function expandNotificationsRequest(isLoadingMore) {
|
||||
return {
|
||||
type: NOTIFICATIONS_EXPAND_REQUEST,
|
||||
skipLoading: !isLoadingMore,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandNotificationsSuccess(notifications, next, isLoadingMore, isLoadingRecent, usePendingItems) {
|
||||
return {
|
||||
type: NOTIFICATIONS_EXPAND_SUCCESS,
|
||||
notifications,
|
||||
next,
|
||||
isLoadingRecent: isLoadingRecent,
|
||||
usePendingItems,
|
||||
skipLoading: !isLoadingMore,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandNotificationsFail(error, isLoadingMore) {
|
||||
return {
|
||||
type: NOTIFICATIONS_EXPAND_FAIL,
|
||||
error,
|
||||
skipLoading: !isLoadingMore,
|
||||
skipAlert: !isLoadingMore || error.name === 'AbortError',
|
||||
};
|
||||
}
|
||||
|
||||
export function scrollTopNotifications(top) {
|
||||
return {
|
||||
type: NOTIFICATIONS_SCROLL_TOP,
|
||||
top,
|
||||
};
|
||||
}
|
||||
|
||||
export function setFilter (filterType) {
|
||||
return dispatch => {
|
||||
dispatch({
|
||||
type: NOTIFICATIONS_FILTER_SET,
|
||||
path: ['notifications', 'quickFilter', 'active'],
|
||||
value: filterType,
|
||||
});
|
||||
dispatch(expandNotifications({ forceLoad: true }));
|
||||
dispatch(saveSettings());
|
||||
};
|
||||
}
|
||||
|
||||
export const mountNotifications = () => ({
|
||||
type: NOTIFICATIONS_MOUNT,
|
||||
});
|
||||
|
||||
export const unmountNotifications = () => ({
|
||||
type: NOTIFICATIONS_UNMOUNT,
|
||||
});
|
||||
|
||||
|
||||
export const markNotificationsAsRead = () => ({
|
||||
type: NOTIFICATIONS_MARK_AS_READ,
|
||||
});
|
||||
|
||||
// Browser support
|
||||
export function setupBrowserNotifications() {
|
||||
return dispatch => {
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
import { createAppAsyncThunk } from 'mastodon/store';
|
||||
|
||||
import { fetchNotifications } from './notification_groups';
|
||||
|
||||
export const initializeNotifications = createAppAsyncThunk(
|
||||
'notifications/initialize',
|
||||
(_, { dispatch }) => {
|
||||
void dispatch(fetchNotifications());
|
||||
},
|
||||
);
|
||||
|
|
@ -9,7 +9,6 @@ export const notificationsUpdate = createAction(
|
|||
...args
|
||||
}: {
|
||||
notification: ApiNotificationJSON;
|
||||
usePendingItems: boolean;
|
||||
playSound: boolean;
|
||||
}) => ({
|
||||
payload: args,
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
import api from '../api';
|
||||
|
||||
import { importFetchedPoll } from './importer';
|
||||
|
||||
export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST';
|
||||
export const POLL_VOTE_SUCCESS = 'POLL_VOTE_SUCCESS';
|
||||
export const POLL_VOTE_FAIL = 'POLL_VOTE_FAIL';
|
||||
|
||||
export const POLL_FETCH_REQUEST = 'POLL_FETCH_REQUEST';
|
||||
export const POLL_FETCH_SUCCESS = 'POLL_FETCH_SUCCESS';
|
||||
export const POLL_FETCH_FAIL = 'POLL_FETCH_FAIL';
|
||||
|
||||
export const vote = (pollId, choices) => (dispatch) => {
|
||||
dispatch(voteRequest());
|
||||
|
||||
api().post(`/api/v1/polls/${pollId}/votes`, { choices })
|
||||
.then(({ data }) => {
|
||||
dispatch(importFetchedPoll(data));
|
||||
dispatch(voteSuccess(data));
|
||||
})
|
||||
.catch(err => dispatch(voteFail(err)));
|
||||
};
|
||||
|
||||
export const fetchPoll = pollId => (dispatch) => {
|
||||
dispatch(fetchPollRequest());
|
||||
|
||||
api().get(`/api/v1/polls/${pollId}`)
|
||||
.then(({ data }) => {
|
||||
dispatch(importFetchedPoll(data));
|
||||
dispatch(fetchPollSuccess(data));
|
||||
})
|
||||
.catch(err => dispatch(fetchPollFail(err)));
|
||||
};
|
||||
|
||||
export const voteRequest = () => ({
|
||||
type: POLL_VOTE_REQUEST,
|
||||
});
|
||||
|
||||
export const voteSuccess = poll => ({
|
||||
type: POLL_VOTE_SUCCESS,
|
||||
poll,
|
||||
});
|
||||
|
||||
export const voteFail = error => ({
|
||||
type: POLL_VOTE_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
export const fetchPollRequest = () => ({
|
||||
type: POLL_FETCH_REQUEST,
|
||||
});
|
||||
|
||||
export const fetchPollSuccess = poll => ({
|
||||
type: POLL_FETCH_SUCCESS,
|
||||
poll,
|
||||
});
|
||||
|
||||
export const fetchPollFail = error => ({
|
||||
type: POLL_FETCH_FAIL,
|
||||
error,
|
||||
});
|
||||
40
app/javascript/mastodon/actions/polls.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { apiGetPoll, apiPollVote } from 'mastodon/api/polls';
|
||||
import type { ApiPollJSON } from 'mastodon/api_types/polls';
|
||||
import { createPollFromServerJSON } from 'mastodon/models/poll';
|
||||
import {
|
||||
createAppAsyncThunk,
|
||||
createDataLoadingThunk,
|
||||
} from 'mastodon/store/typed_functions';
|
||||
|
||||
import { importPolls } from './importer/polls';
|
||||
|
||||
export const importFetchedPoll = createAppAsyncThunk(
|
||||
'poll/importFetched',
|
||||
(args: { poll: ApiPollJSON }, { dispatch, getState }) => {
|
||||
const { poll } = args;
|
||||
|
||||
dispatch(
|
||||
importPolls({
|
||||
polls: [createPollFromServerJSON(poll, getState().polls[poll.id])],
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const vote = createDataLoadingThunk(
|
||||
'poll/vote',
|
||||
({ pollId, choices }: { pollId: string; choices: string[] }) =>
|
||||
apiPollVote(pollId, choices),
|
||||
async (poll, { dispatch, discardLoadData }) => {
|
||||
await dispatch(importFetchedPoll({ poll }));
|
||||
return discardLoadData;
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchPoll = createDataLoadingThunk(
|
||||
'poll/fetch',
|
||||
({ pollId }: { pollId: string }) => apiGetPoll(pollId),
|
||||
async (poll, { dispatch }) => {
|
||||
await dispatch(importFetchedPoll({ poll }));
|
||||
},
|
||||
);
|
||||
|
|
@ -33,7 +33,7 @@ const unsubscribe = ({ registration, subscription }) =>
|
|||
subscription ? subscription.unsubscribe().then(() => registration) : registration;
|
||||
|
||||
const sendSubscriptionToBackend = (subscription) => {
|
||||
const params = { subscription };
|
||||
const params = { subscription: { ...subscription.toJSON(), standard: true } };
|
||||
|
||||
if (me) {
|
||||
const data = pushNotificationsSetting.get(me);
|
||||
|
|
|
|||
|
|
@ -1,215 +0,0 @@
|
|||
import { fromJS } from 'immutable';
|
||||
|
||||
import { searchHistory } from 'mastodon/settings';
|
||||
|
||||
import api from '../api';
|
||||
|
||||
import { fetchRelationships } from './accounts';
|
||||
import { importFetchedAccounts, importFetchedStatuses } from './importer';
|
||||
|
||||
export const SEARCH_CHANGE = 'SEARCH_CHANGE';
|
||||
export const SEARCH_CLEAR = 'SEARCH_CLEAR';
|
||||
export const SEARCH_SHOW = 'SEARCH_SHOW';
|
||||
|
||||
export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST';
|
||||
export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS';
|
||||
export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL';
|
||||
|
||||
export const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST';
|
||||
export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS';
|
||||
export const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL';
|
||||
|
||||
export const SEARCH_HISTORY_UPDATE = 'SEARCH_HISTORY_UPDATE';
|
||||
|
||||
export function changeSearch(value) {
|
||||
return {
|
||||
type: SEARCH_CHANGE,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearSearch() {
|
||||
return {
|
||||
type: SEARCH_CLEAR,
|
||||
};
|
||||
}
|
||||
|
||||
export function submitSearch(type) {
|
||||
return (dispatch, getState) => {
|
||||
const value = getState().getIn(['search', 'value']);
|
||||
const signedIn = !!getState().getIn(['meta', 'me']);
|
||||
|
||||
if (value.length === 0) {
|
||||
dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, '', type));
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchSearchRequest(type));
|
||||
|
||||
api().get('/api/v2/search', {
|
||||
params: {
|
||||
q: value,
|
||||
resolve: signedIn,
|
||||
limit: 11,
|
||||
type,
|
||||
},
|
||||
}).then(response => {
|
||||
if (response.data.accounts) {
|
||||
dispatch(importFetchedAccounts(response.data.accounts));
|
||||
}
|
||||
|
||||
if (response.data.statuses) {
|
||||
dispatch(importFetchedStatuses(response.data.statuses));
|
||||
}
|
||||
|
||||
dispatch(fetchSearchSuccess(response.data, value, type));
|
||||
dispatch(fetchRelationships(response.data.accounts.map(item => item.id)));
|
||||
}).catch(error => {
|
||||
dispatch(fetchSearchFail(error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchSearchRequest(searchType) {
|
||||
return {
|
||||
type: SEARCH_FETCH_REQUEST,
|
||||
searchType,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchSearchSuccess(results, searchTerm, searchType) {
|
||||
return {
|
||||
type: SEARCH_FETCH_SUCCESS,
|
||||
results,
|
||||
searchType,
|
||||
searchTerm,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchSearchFail(error) {
|
||||
return {
|
||||
type: SEARCH_FETCH_FAIL,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export const expandSearch = type => (dispatch, getState) => {
|
||||
const value = getState().getIn(['search', 'value']);
|
||||
const offset = getState().getIn(['search', 'results', type]).size - 1;
|
||||
|
||||
dispatch(expandSearchRequest(type));
|
||||
|
||||
api().get('/api/v2/search', {
|
||||
params: {
|
||||
q: value,
|
||||
type,
|
||||
offset,
|
||||
limit: 11,
|
||||
},
|
||||
}).then(({ data }) => {
|
||||
if (data.accounts) {
|
||||
dispatch(importFetchedAccounts(data.accounts));
|
||||
}
|
||||
|
||||
if (data.statuses) {
|
||||
dispatch(importFetchedStatuses(data.statuses));
|
||||
}
|
||||
|
||||
dispatch(expandSearchSuccess(data, value, type));
|
||||
dispatch(fetchRelationships(data.accounts.map(item => item.id)));
|
||||
}).catch(error => {
|
||||
dispatch(expandSearchFail(error));
|
||||
});
|
||||
};
|
||||
|
||||
export const expandSearchRequest = (searchType) => ({
|
||||
type: SEARCH_EXPAND_REQUEST,
|
||||
searchType,
|
||||
});
|
||||
|
||||
export const expandSearchSuccess = (results, searchTerm, searchType) => ({
|
||||
type: SEARCH_EXPAND_SUCCESS,
|
||||
results,
|
||||
searchTerm,
|
||||
searchType,
|
||||
});
|
||||
|
||||
export const expandSearchFail = error => ({
|
||||
type: SEARCH_EXPAND_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
export const showSearch = () => ({
|
||||
type: SEARCH_SHOW,
|
||||
});
|
||||
|
||||
export const openURL = (value, history, onFailure) => (dispatch, getState) => {
|
||||
const signedIn = !!getState().getIn(['meta', 'me']);
|
||||
|
||||
if (!signedIn) {
|
||||
if (onFailure) {
|
||||
onFailure();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchSearchRequest());
|
||||
|
||||
api().get('/api/v2/search', { params: { q: value, resolve: true } }).then(response => {
|
||||
if (response.data.accounts?.length > 0) {
|
||||
dispatch(importFetchedAccounts(response.data.accounts));
|
||||
history.push(`/@${response.data.accounts[0].acct}`);
|
||||
} else if (response.data.statuses?.length > 0) {
|
||||
dispatch(importFetchedStatuses(response.data.statuses));
|
||||
history.push(`/@${response.data.statuses[0].account.acct}/${response.data.statuses[0].id}`);
|
||||
} else if (onFailure) {
|
||||
onFailure();
|
||||
}
|
||||
|
||||
dispatch(fetchSearchSuccess(response.data, value));
|
||||
}).catch(err => {
|
||||
dispatch(fetchSearchFail(err));
|
||||
|
||||
if (onFailure) {
|
||||
onFailure();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const clickSearchResult = (q, type) => (dispatch, getState) => {
|
||||
const previous = getState().getIn(['search', 'recent']);
|
||||
|
||||
if (previous.some(x => x.get('q') === q && x.get('type') === type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const me = getState().getIn(['meta', 'me']);
|
||||
const current = previous.add(fromJS({ type, q })).takeLast(4);
|
||||
|
||||
searchHistory.set(me, current.toJS());
|
||||
dispatch(updateSearchHistory(current));
|
||||
};
|
||||
|
||||
export const forgetSearchResult = q => (dispatch, getState) => {
|
||||
const previous = getState().getIn(['search', 'recent']);
|
||||
const me = getState().getIn(['meta', 'me']);
|
||||
const current = previous.filterNot(result => result.get('q') === q);
|
||||
|
||||
searchHistory.set(me, current.toJS());
|
||||
dispatch(updateSearchHistory(current));
|
||||
};
|
||||
|
||||
export const updateSearchHistory = recent => ({
|
||||
type: SEARCH_HISTORY_UPDATE,
|
||||
recent,
|
||||
});
|
||||
|
||||
export const hydrateSearch = () => (dispatch, getState) => {
|
||||
const me = getState().getIn(['meta', 'me']);
|
||||
const history = searchHistory.get(me);
|
||||
|
||||
if (history !== null) {
|
||||
dispatch(updateSearchHistory(history));
|
||||
}
|
||||
};
|
||||
153
app/javascript/mastodon/actions/search.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { apiGetSearch } from 'mastodon/api/search';
|
||||
import type { ApiSearchType } from 'mastodon/api_types/search';
|
||||
import type {
|
||||
RecentSearch,
|
||||
SearchType as RecentSearchType,
|
||||
} from 'mastodon/models/search';
|
||||
import { searchHistory } from 'mastodon/settings';
|
||||
import {
|
||||
createDataLoadingThunk,
|
||||
createAppAsyncThunk,
|
||||
} from 'mastodon/store/typed_functions';
|
||||
|
||||
import { fetchRelationships } from './accounts';
|
||||
import { importFetchedAccounts, importFetchedStatuses } from './importer';
|
||||
|
||||
export const SEARCH_HISTORY_UPDATE = 'SEARCH_HISTORY_UPDATE';
|
||||
|
||||
export const submitSearch = createDataLoadingThunk(
|
||||
'search/submit',
|
||||
async ({ q, type }: { q: string; type?: ApiSearchType }, { getState }) => {
|
||||
const signedIn = !!getState().meta.get('me');
|
||||
|
||||
return apiGetSearch({
|
||||
q,
|
||||
type,
|
||||
resolve: signedIn,
|
||||
limit: 11,
|
||||
});
|
||||
},
|
||||
(data, { dispatch }) => {
|
||||
if (data.accounts.length > 0) {
|
||||
dispatch(importFetchedAccounts(data.accounts));
|
||||
dispatch(fetchRelationships(data.accounts.map((account) => account.id)));
|
||||
}
|
||||
|
||||
if (data.statuses.length > 0) {
|
||||
dispatch(importFetchedStatuses(data.statuses));
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
{
|
||||
useLoadingBar: false,
|
||||
},
|
||||
);
|
||||
|
||||
export const expandSearch = createDataLoadingThunk(
|
||||
'search/expand',
|
||||
async ({ type }: { type: ApiSearchType }, { getState }) => {
|
||||
const q = getState().search.q;
|
||||
const results = getState().search.results;
|
||||
const offset = results?.[type].length;
|
||||
|
||||
return apiGetSearch({
|
||||
q,
|
||||
type,
|
||||
limit: 10,
|
||||
offset,
|
||||
});
|
||||
},
|
||||
(data, { dispatch }) => {
|
||||
if (data.accounts.length > 0) {
|
||||
dispatch(importFetchedAccounts(data.accounts));
|
||||
dispatch(fetchRelationships(data.accounts.map((account) => account.id)));
|
||||
}
|
||||
|
||||
if (data.statuses.length > 0) {
|
||||
dispatch(importFetchedStatuses(data.statuses));
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
{
|
||||
useLoadingBar: true,
|
||||
},
|
||||
);
|
||||
|
||||
export const openURL = createDataLoadingThunk(
|
||||
'search/openURL',
|
||||
({ url }: { url: string }) =>
|
||||
apiGetSearch({
|
||||
q: url,
|
||||
resolve: true,
|
||||
limit: 1,
|
||||
}),
|
||||
(data, { dispatch }) => {
|
||||
if (data.accounts.length > 0) {
|
||||
dispatch(importFetchedAccounts(data.accounts));
|
||||
} else if (data.statuses.length > 0) {
|
||||
dispatch(importFetchedStatuses(data.statuses));
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
{
|
||||
useLoadingBar: true,
|
||||
},
|
||||
);
|
||||
|
||||
export const clickSearchResult = createAppAsyncThunk(
|
||||
'search/clickResult',
|
||||
(
|
||||
{ q, type }: { q: string; type?: RecentSearchType },
|
||||
{ dispatch, getState },
|
||||
) => {
|
||||
const previous = getState().search.recent;
|
||||
|
||||
if (previous.some((x) => x.q === q && x.type === type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const me = getState().meta.get('me') as string;
|
||||
const current = [{ type, q }, ...previous].slice(0, 4);
|
||||
|
||||
searchHistory.set(me, current);
|
||||
dispatch(updateSearchHistory(current));
|
||||
},
|
||||
);
|
||||
|
||||
export const forgetSearchResult = createAppAsyncThunk(
|
||||
'search/forgetResult',
|
||||
(
|
||||
{ q, type }: { q: string; type?: RecentSearchType },
|
||||
{ dispatch, getState },
|
||||
) => {
|
||||
const previous = getState().search.recent;
|
||||
const me = getState().meta.get('me') as string;
|
||||
const current = previous.filter(
|
||||
(result) => result.q !== q || result.type !== type,
|
||||
);
|
||||
|
||||
searchHistory.set(me, current);
|
||||
dispatch(updateSearchHistory(current));
|
||||
},
|
||||
);
|
||||
|
||||
export const updateSearchHistory = createAction<RecentSearch[]>(
|
||||
'search/updateHistory',
|
||||
);
|
||||
|
||||
export const hydrateSearch = createAppAsyncThunk(
|
||||
'search/hydrate',
|
||||
(_args, { dispatch, getState }) => {
|
||||
const me = getState().meta.get('me') as string;
|
||||
const history = searchHistory.get(me) as RecentSearch[] | null;
|
||||
|
||||
if (history !== null) {
|
||||
dispatch(updateSearchHistory(history));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -29,7 +29,7 @@ const debouncedSave = debounce((dispatch, getState) => {
|
|||
api().put('/api/web/settings', { data })
|
||||
.then(() => dispatch({ type: SETTING_SAVE }))
|
||||
.catch(error => dispatch(showAlertForError(error)));
|
||||
}, 5000, { trailing: true });
|
||||
}, 2000, { leading: true, trailing: true });
|
||||
|
||||
export function saveSettings() {
|
||||
return (dispatch, getState) => debouncedSave(dispatch, getState);
|
||||
|
|
|
|||
|
|
@ -3,9 +3,12 @@ import { browserHistory } from 'mastodon/components/router';
|
|||
import api from '../api';
|
||||
|
||||
import { ensureComposeIsVisible, setComposeToStatus } from './compose';
|
||||
import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer';
|
||||
import { importFetchedStatus, importFetchedAccount } from './importer';
|
||||
import { fetchContext } from './statuses_typed';
|
||||
import { deleteFromTimelines } from './timelines';
|
||||
|
||||
export * from './statuses_typed';
|
||||
|
||||
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
|
||||
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
|
||||
export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL';
|
||||
|
|
@ -14,10 +17,6 @@ export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST';
|
|||
export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS';
|
||||
export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL';
|
||||
|
||||
export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST';
|
||||
export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS';
|
||||
export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL';
|
||||
|
||||
export const STATUS_MUTE_REQUEST = 'STATUS_MUTE_REQUEST';
|
||||
export const STATUS_MUTE_SUCCESS = 'STATUS_MUTE_SUCCESS';
|
||||
export const STATUS_MUTE_FAIL = 'STATUS_MUTE_FAIL';
|
||||
|
|
@ -49,12 +48,23 @@ export function fetchStatusRequest(id, skipLoading) {
|
|||
};
|
||||
}
|
||||
|
||||
export function fetchStatus(id, forceFetch = false, alsoFetchContext = true) {
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.forceFetch]
|
||||
* @param {boolean} [options.alsoFetchContext]
|
||||
* @param {string | null | undefined} [options.parentQuotePostId]
|
||||
*/
|
||||
export function fetchStatus(id, {
|
||||
forceFetch = false,
|
||||
alsoFetchContext = true,
|
||||
parentQuotePostId,
|
||||
} = {}) {
|
||||
return (dispatch, getState) => {
|
||||
const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null;
|
||||
|
||||
if (alsoFetchContext) {
|
||||
dispatch(fetchContext(id));
|
||||
dispatch(fetchContext({ statusId: id }));
|
||||
}
|
||||
|
||||
if (skipLoading) {
|
||||
|
|
@ -67,7 +77,7 @@ export function fetchStatus(id, forceFetch = false, alsoFetchContext = true) {
|
|||
dispatch(importFetchedStatus(response.data));
|
||||
dispatch(fetchStatusSuccess(skipLoading));
|
||||
}).catch(error => {
|
||||
dispatch(fetchStatusFail(id, error, skipLoading));
|
||||
dispatch(fetchStatusFail(id, error, skipLoading, parentQuotePostId));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
@ -79,21 +89,27 @@ export function fetchStatusSuccess(skipLoading) {
|
|||
};
|
||||
}
|
||||
|
||||
export function fetchStatusFail(id, error, skipLoading) {
|
||||
export function fetchStatusFail(id, error, skipLoading, parentQuotePostId) {
|
||||
return {
|
||||
type: STATUS_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
parentQuotePostId,
|
||||
skipLoading,
|
||||
skipAlert: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function redraft(status, raw_text) {
|
||||
return {
|
||||
type: REDRAFT,
|
||||
status,
|
||||
raw_text,
|
||||
return (dispatch, getState) => {
|
||||
const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']);
|
||||
|
||||
dispatch({
|
||||
type: REDRAFT,
|
||||
status,
|
||||
raw_text,
|
||||
maxOptions,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -138,7 +154,7 @@ export function deleteStatus(id, withRedraft = false) {
|
|||
|
||||
dispatch(deleteStatusRequest(id));
|
||||
|
||||
api().delete(`/api/v1/statuses/${id}`).then(response => {
|
||||
api().delete(`/api/v1/statuses/${id}`, { params: { delete_media: !withRedraft } }).then(response => {
|
||||
dispatch(deleteStatusSuccess(id));
|
||||
dispatch(deleteFromTimelines(id));
|
||||
dispatch(importFetchedAccount(response.data.account));
|
||||
|
|
@ -178,50 +194,6 @@ export function deleteStatusFail(id, error) {
|
|||
export const updateStatus = status => dispatch =>
|
||||
dispatch(importFetchedStatus(status));
|
||||
|
||||
export function fetchContext(id) {
|
||||
return (dispatch) => {
|
||||
dispatch(fetchContextRequest(id));
|
||||
|
||||
api().get(`/api/v1/statuses/${id}/context`).then(response => {
|
||||
dispatch(importFetchedStatuses(response.data.ancestors.concat(response.data.descendants)));
|
||||
dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants));
|
||||
|
||||
}).catch(error => {
|
||||
if (error.response && error.response.status === 404) {
|
||||
dispatch(deleteFromTimelines(id));
|
||||
}
|
||||
|
||||
dispatch(fetchContextFail(id, error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchContextRequest(id) {
|
||||
return {
|
||||
type: CONTEXT_FETCH_REQUEST,
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchContextSuccess(id, ancestors, descendants) {
|
||||
return {
|
||||
type: CONTEXT_FETCH_SUCCESS,
|
||||
id,
|
||||
ancestors,
|
||||
descendants,
|
||||
statuses: ancestors.concat(descendants),
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchContextFail(id, error) {
|
||||
return {
|
||||
type: CONTEXT_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
skipAlert: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function muteStatus(id) {
|
||||
return (dispatch) => {
|
||||
dispatch(muteStatusRequest(id));
|
||||
|
|
|
|||
18
app/javascript/mastodon/actions/statuses_typed.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { apiGetContext } from 'mastodon/api/statuses';
|
||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||
|
||||
import { importFetchedStatuses } from './importer';
|
||||
|
||||
export const fetchContext = createDataLoadingThunk(
|
||||
'status/context',
|
||||
({ statusId }: { statusId: string }) => apiGetContext(statusId),
|
||||
(context, { dispatch }) => {
|
||||
const statuses = context.ancestors.concat(context.descendants);
|
||||
|
||||
dispatch(importFetchedStatuses(statuses));
|
||||
|
||||
return {
|
||||
context,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Iterable, fromJS } from 'immutable';
|
||||
import { fromJS, isIndexed } from 'immutable';
|
||||
|
||||
import { hydrateCompose } from './compose';
|
||||
import { importFetchedAccounts } from './importer';
|
||||
|
|
@ -9,8 +9,7 @@ export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY';
|
|||
|
||||
const convertState = rawState =>
|
||||
fromJS(rawState, (k, v) =>
|
||||
Iterable.isIndexed(v) ? v.toList() : v.toMap());
|
||||
|
||||
isIndexed(v) ? v.toList() : v.toMap());
|
||||
|
||||
export function hydrateStore(rawState) {
|
||||
return dispatch => {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
} from './announcements';
|
||||
import { updateConversations } from './conversations';
|
||||
import { processNewNotificationForGroups, refreshStaleNotificationGroups, pollRecentNotifications as pollRecentGroupNotifications } from './notification_groups';
|
||||
import { updateNotifications, expandNotifications } from './notifications';
|
||||
import { updateNotifications } from './notifications';
|
||||
import { updateStatus } from './statuses';
|
||||
import {
|
||||
updateTimeline,
|
||||
|
|
@ -106,12 +106,10 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
|
|||
dispatch(processNewNotificationForGroups(notificationJSON));
|
||||
break;
|
||||
}
|
||||
case 'notifications_merged':
|
||||
const state = getState();
|
||||
if (state.notifications.top || !state.notifications.mounted)
|
||||
dispatch(expandNotifications({ forceLoad: true, maxId: undefined }));
|
||||
case 'notifications_merged': {
|
||||
dispatch(refreshStaleNotificationGroups());
|
||||
break;
|
||||
}
|
||||
case 'conversation':
|
||||
// @ts-expect-error
|
||||
dispatch(updateConversations(JSON.parse(data.payload)));
|
||||
|
|
|
|||
|
|
@ -1,58 +0,0 @@
|
|||
import api from '../api';
|
||||
|
||||
import { fetchRelationships } from './accounts';
|
||||
import { importFetchedAccounts } from './importer';
|
||||
|
||||
export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST';
|
||||
export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS';
|
||||
export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL';
|
||||
|
||||
export const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS';
|
||||
|
||||
export function fetchSuggestions(withRelationships = false) {
|
||||
return (dispatch) => {
|
||||
dispatch(fetchSuggestionsRequest());
|
||||
|
||||
api().get('/api/v2/suggestions', { params: { limit: 20 } }).then(response => {
|
||||
dispatch(importFetchedAccounts(response.data.map(x => x.account)));
|
||||
dispatch(fetchSuggestionsSuccess(response.data));
|
||||
|
||||
if (withRelationships) {
|
||||
dispatch(fetchRelationships(response.data.map(item => item.account.id)));
|
||||
}
|
||||
}).catch(error => dispatch(fetchSuggestionsFail(error)));
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchSuggestionsRequest() {
|
||||
return {
|
||||
type: SUGGESTIONS_FETCH_REQUEST,
|
||||
skipLoading: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchSuggestionsSuccess(suggestions) {
|
||||
return {
|
||||
type: SUGGESTIONS_FETCH_SUCCESS,
|
||||
suggestions,
|
||||
skipLoading: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchSuggestionsFail(error) {
|
||||
return {
|
||||
type: SUGGESTIONS_FETCH_FAIL,
|
||||
error,
|
||||
skipLoading: true,
|
||||
skipAlert: true,
|
||||
};
|
||||
}
|
||||
|
||||
export const dismissSuggestion = accountId => (dispatch) => {
|
||||
dispatch({
|
||||
type: SUGGESTIONS_DISMISS,
|
||||
id: accountId,
|
||||
});
|
||||
|
||||
api().delete(`/api/v1/suggestions/${accountId}`).catch(() => {});
|
||||
};
|
||||
24
app/javascript/mastodon/actions/suggestions.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import {
|
||||
apiGetSuggestions,
|
||||
apiDeleteSuggestion,
|
||||
} from 'mastodon/api/suggestions';
|
||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||
|
||||
import { fetchRelationships } from './accounts';
|
||||
import { importFetchedAccounts } from './importer';
|
||||
|
||||
export const fetchSuggestions = createDataLoadingThunk(
|
||||
'suggestions/fetch',
|
||||
() => apiGetSuggestions(20),
|
||||
(data, { dispatch }) => {
|
||||
dispatch(importFetchedAccounts(data.map((x) => x.account)));
|
||||
dispatch(fetchRelationships(data.map((x) => x.account.id)));
|
||||
|
||||
return data;
|
||||
},
|
||||
);
|
||||
|
||||
export const dismissSuggestion = createDataLoadingThunk(
|
||||
'suggestions/dismiss',
|
||||
({ accountId }: { accountId: string }) => apiDeleteSuggestion(accountId),
|
||||
);
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
import api, { getLinks } from '../api';
|
||||
|
||||
export const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST';
|
||||
export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
|
||||
export const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL';
|
||||
|
||||
export const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST';
|
||||
export const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS';
|
||||
export const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL';
|
||||
|
||||
export const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST';
|
||||
export const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS';
|
||||
export const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
|
||||
|
||||
export const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST';
|
||||
export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
|
||||
export const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL';
|
||||
|
||||
export const HASHTAG_UNFOLLOW_REQUEST = 'HASHTAG_UNFOLLOW_REQUEST';
|
||||
export const HASHTAG_UNFOLLOW_SUCCESS = 'HASHTAG_UNFOLLOW_SUCCESS';
|
||||
export const HASHTAG_UNFOLLOW_FAIL = 'HASHTAG_UNFOLLOW_FAIL';
|
||||
|
||||
export const fetchHashtag = name => (dispatch) => {
|
||||
dispatch(fetchHashtagRequest());
|
||||
|
||||
api().get(`/api/v1/tags/${name}`).then(({ data }) => {
|
||||
dispatch(fetchHashtagSuccess(name, data));
|
||||
}).catch(err => {
|
||||
dispatch(fetchHashtagFail(err));
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchHashtagRequest = () => ({
|
||||
type: HASHTAG_FETCH_REQUEST,
|
||||
});
|
||||
|
||||
export const fetchHashtagSuccess = (name, tag) => ({
|
||||
type: HASHTAG_FETCH_SUCCESS,
|
||||
name,
|
||||
tag,
|
||||
});
|
||||
|
||||
export const fetchHashtagFail = error => ({
|
||||
type: HASHTAG_FETCH_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
export const fetchFollowedHashtags = () => (dispatch) => {
|
||||
dispatch(fetchFollowedHashtagsRequest());
|
||||
|
||||
api().get('/api/v1/followed_tags').then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null));
|
||||
}).catch(err => {
|
||||
dispatch(fetchFollowedHashtagsFail(err));
|
||||
});
|
||||
};
|
||||
|
||||
export function fetchFollowedHashtagsRequest() {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_FETCH_REQUEST,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchFollowedHashtagsSuccess(followed_tags, next) {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_FETCH_SUCCESS,
|
||||
followed_tags,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchFollowedHashtagsFail(error) {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_FETCH_FAIL,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandFollowedHashtags() {
|
||||
return (dispatch, getState) => {
|
||||
const url = getState().getIn(['followed_tags', 'next']);
|
||||
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandFollowedHashtagsRequest());
|
||||
|
||||
api().get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null));
|
||||
}).catch(error => {
|
||||
dispatch(expandFollowedHashtagsFail(error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function expandFollowedHashtagsRequest() {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_REQUEST,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandFollowedHashtagsSuccess(followed_tags, next) {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
|
||||
followed_tags,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandFollowedHashtagsFail(error) {
|
||||
return {
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_FAIL,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export const followHashtag = name => (dispatch) => {
|
||||
dispatch(followHashtagRequest(name));
|
||||
|
||||
api().post(`/api/v1/tags/${name}/follow`).then(({ data }) => {
|
||||
dispatch(followHashtagSuccess(name, data));
|
||||
}).catch(err => {
|
||||
dispatch(followHashtagFail(name, err));
|
||||
});
|
||||
};
|
||||
|
||||
export const followHashtagRequest = name => ({
|
||||
type: HASHTAG_FOLLOW_REQUEST,
|
||||
name,
|
||||
});
|
||||
|
||||
export const followHashtagSuccess = (name, tag) => ({
|
||||
type: HASHTAG_FOLLOW_SUCCESS,
|
||||
name,
|
||||
tag,
|
||||
});
|
||||
|
||||
export const followHashtagFail = (name, error) => ({
|
||||
type: HASHTAG_FOLLOW_FAIL,
|
||||
name,
|
||||
error,
|
||||
});
|
||||
|
||||
export const unfollowHashtag = name => (dispatch) => {
|
||||
dispatch(unfollowHashtagRequest(name));
|
||||
|
||||
api().post(`/api/v1/tags/${name}/unfollow`).then(({ data }) => {
|
||||
dispatch(unfollowHashtagSuccess(name, data));
|
||||
}).catch(err => {
|
||||
dispatch(unfollowHashtagFail(name, err));
|
||||
});
|
||||
};
|
||||
|
||||
export const unfollowHashtagRequest = name => ({
|
||||
type: HASHTAG_UNFOLLOW_REQUEST,
|
||||
name,
|
||||
});
|
||||
|
||||
export const unfollowHashtagSuccess = (name, tag) => ({
|
||||
type: HASHTAG_UNFOLLOW_SUCCESS,
|
||||
name,
|
||||
tag,
|
||||
});
|
||||
|
||||
export const unfollowHashtagFail = (name, error) => ({
|
||||
type: HASHTAG_UNFOLLOW_FAIL,
|
||||
name,
|
||||
error,
|
||||
});
|
||||
54
app/javascript/mastodon/actions/tags_typed.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import {
|
||||
apiGetTag,
|
||||
apiFollowTag,
|
||||
apiUnfollowTag,
|
||||
apiFeatureTag,
|
||||
apiUnfeatureTag,
|
||||
apiGetFollowedTags,
|
||||
} from 'mastodon/api/tags';
|
||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||
|
||||
export const fetchFollowedHashtags = createDataLoadingThunk(
|
||||
'tags/fetch-followed',
|
||||
async ({ next }: { next?: string } = {}) => {
|
||||
const response = await apiGetFollowedTags(next);
|
||||
return {
|
||||
...response,
|
||||
replace: !next,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export const markFollowedHashtagsStale = createAction(
|
||||
'tags/mark-followed-stale',
|
||||
);
|
||||
|
||||
export const fetchHashtag = createDataLoadingThunk(
|
||||
'tags/fetch',
|
||||
({ tagId }: { tagId: string }) => apiGetTag(tagId),
|
||||
);
|
||||
|
||||
export const followHashtag = createDataLoadingThunk(
|
||||
'tags/follow',
|
||||
({ tagId }: { tagId: string }) => apiFollowTag(tagId),
|
||||
(_, { dispatch }) => {
|
||||
void dispatch(markFollowedHashtagsStale());
|
||||
},
|
||||
);
|
||||
|
||||
export const unfollowHashtag = createDataLoadingThunk(
|
||||
'tags/unfollow',
|
||||
({ tagId }: { tagId: string }) => apiUnfollowTag(tagId),
|
||||
);
|
||||
|
||||
export const featureHashtag = createDataLoadingThunk(
|
||||
'tags/feature',
|
||||
({ tagId }: { tagId: string }) => apiFeatureTag(tagId),
|
||||
);
|
||||
|
||||
export const unfeatureHashtag = createDataLoadingThunk(
|
||||
'tags/unfeature',
|
||||
({ tagId }: { tagId: string }) => apiUnfeatureTag(tagId),
|
||||
);
|
||||
|
|
@ -1,4 +1,9 @@
|
|||
import type { AxiosResponse, Method, RawAxiosRequestHeaders } from 'axios';
|
||||
import type {
|
||||
AxiosError,
|
||||
AxiosResponse,
|
||||
Method,
|
||||
RawAxiosRequestHeaders,
|
||||
} from 'axios';
|
||||
import axios from 'axios';
|
||||
import LinkHeader from 'http-link-header';
|
||||
|
||||
|
|
@ -41,7 +46,7 @@ const authorizationTokenFromInitialState = (): RawAxiosRequestHeaders => {
|
|||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function api(withAuthorization = true) {
|
||||
return axios.create({
|
||||
const instance = axios.create({
|
||||
transitional: {
|
||||
clarifyTimeoutError: true,
|
||||
},
|
||||
|
|
@ -60,14 +65,32 @@ export default function api(withAuthorization = true) {
|
|||
},
|
||||
],
|
||||
});
|
||||
|
||||
instance.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
if (response.headers.deprecation) {
|
||||
console.warn(
|
||||
`Deprecated request: ${response.config.method} ${response.config.url}`,
|
||||
);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
type ApiUrl = `v${1 | 2}/${string}`;
|
||||
type RequestParamsOrData = Record<string, unknown>;
|
||||
|
||||
export async function apiRequest<ApiResponse = unknown>(
|
||||
method: Method,
|
||||
url: string,
|
||||
args: {
|
||||
signal?: AbortSignal;
|
||||
params?: RequestParamsOrData;
|
||||
data?: RequestParamsOrData;
|
||||
timeout?: number;
|
||||
|
|
@ -83,28 +106,28 @@ export async function apiRequest<ApiResponse = unknown>(
|
|||
}
|
||||
|
||||
export async function apiRequestGet<ApiResponse = unknown>(
|
||||
url: string,
|
||||
url: ApiUrl,
|
||||
params?: RequestParamsOrData,
|
||||
) {
|
||||
return apiRequest<ApiResponse>('GET', url, { params });
|
||||
}
|
||||
|
||||
export async function apiRequestPost<ApiResponse = unknown>(
|
||||
url: string,
|
||||
url: ApiUrl,
|
||||
data?: RequestParamsOrData,
|
||||
) {
|
||||
return apiRequest<ApiResponse>('POST', url, { data });
|
||||
}
|
||||
|
||||
export async function apiRequestPut<ApiResponse = unknown>(
|
||||
url: string,
|
||||
url: ApiUrl,
|
||||
data?: RequestParamsOrData,
|
||||
) {
|
||||
return apiRequest<ApiResponse>('PUT', url, { data });
|
||||
}
|
||||
|
||||
export async function apiRequestDelete<ApiResponse = unknown>(
|
||||
url: string,
|
||||
url: ApiUrl,
|
||||
params?: RequestParamsOrData,
|
||||
) {
|
||||
return apiRequest<ApiResponse>('DELETE', url, { params });
|
||||
|
|
|
|||
|
|
@ -1,7 +1,41 @@
|
|||
import { apiRequestPost } from 'mastodon/api';
|
||||
import { apiRequestPost, apiRequestGet } from 'mastodon/api';
|
||||
import type {
|
||||
ApiAccountJSON,
|
||||
ApiFamiliarFollowersJSON,
|
||||
} from 'mastodon/api_types/accounts';
|
||||
import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships';
|
||||
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
|
||||
|
||||
export const apiSubmitAccountNote = (id: string, value: string) =>
|
||||
apiRequestPost<ApiRelationshipJSON>(`v1/accounts/${id}/note`, {
|
||||
comment: value,
|
||||
});
|
||||
|
||||
export const apiFollowAccount = (
|
||||
id: string,
|
||||
params?: {
|
||||
reblogs: boolean;
|
||||
},
|
||||
) =>
|
||||
apiRequestPost<ApiRelationshipJSON>(`v1/accounts/${id}/follow`, {
|
||||
...params,
|
||||
});
|
||||
|
||||
export const apiUnfollowAccount = (id: string) =>
|
||||
apiRequestPost<ApiRelationshipJSON>(`v1/accounts/${id}/unfollow`);
|
||||
|
||||
export const apiRemoveAccountFromFollowers = (id: string) =>
|
||||
apiRequestPost<ApiRelationshipJSON>(
|
||||
`v1/accounts/${id}/remove_from_followers`,
|
||||
);
|
||||
|
||||
export const apiGetFeaturedTags = (id: string) =>
|
||||
apiRequestGet<ApiHashtagJSON>(`v1/accounts/${id}/featured_tags`);
|
||||
|
||||
export const apiGetEndorsedAccounts = (id: string) =>
|
||||
apiRequestGet<ApiAccountJSON>(`v1/accounts/${id}/endorsements`);
|
||||
|
||||
export const apiGetFamiliarFollowers = (id: string) =>
|
||||
apiRequestGet<ApiFamiliarFollowersJSON>('v1/accounts/familiar_followers', {
|
||||
id,
|
||||
});
|
||||
|
|
|
|||
7
app/javascript/mastodon/api/compose.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { apiRequestPut } from 'mastodon/api';
|
||||
import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments';
|
||||
|
||||
export const apiUpdateMedia = (
|
||||
id: string,
|
||||
params?: { description?: string; focus?: string },
|
||||
) => apiRequestPut<ApiMediaAttachmentJSON>(`v1/media/${id}`, params);
|
||||
13
app/javascript/mastodon/api/domain_blocks.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import api, { getLinks } from 'mastodon/api';
|
||||
|
||||
export const apiGetDomainBlocks = async (url?: string) => {
|
||||
const response = await api().request<string[]>({
|
||||
method: 'GET',
|
||||
url: url ?? '/api/v1/domain_blocks',
|
||||
});
|
||||
|
||||
return {
|
||||
domains: response.data,
|
||||
links: getLinks(response),
|
||||
};
|
||||
};
|
||||
15
app/javascript/mastodon/api/instance.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { apiRequestGet } from 'mastodon/api';
|
||||
import type {
|
||||
ApiTermsOfServiceJSON,
|
||||
ApiPrivacyPolicyJSON,
|
||||
} from 'mastodon/api_types/instance';
|
||||
|
||||
export const apiGetTermsOfService = (version?: string) =>
|
||||
apiRequestGet<ApiTermsOfServiceJSON>(
|
||||
version
|
||||
? `v1/instance/terms_of_service/${version}`
|
||||
: 'v1/instance/terms_of_service',
|
||||
);
|
||||
|
||||
export const apiGetPrivacyPolicy = () =>
|
||||
apiRequestGet<ApiPrivacyPolicyJSON>('v1/instance/privacy_policy');
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import { apiRequestPost } from 'mastodon/api';
|
||||
import type { Status, StatusVisibility } from 'mastodon/models/status';
|
||||
import type { ApiStatusJSON } from 'mastodon/api_types/statuses';
|
||||
import type { StatusVisibility } from 'mastodon/models/status';
|
||||
|
||||
export const apiReblog = (statusId: string, visibility: StatusVisibility) =>
|
||||
apiRequestPost<{ reblog: Status }>(`v1/statuses/${statusId}/reblog`, {
|
||||
apiRequestPost<{ reblog: ApiStatusJSON }>(`v1/statuses/${statusId}/reblog`, {
|
||||
visibility,
|
||||
});
|
||||
|
||||
export const apiUnreblog = (statusId: string) =>
|
||||
apiRequestPost<Status>(`v1/statuses/${statusId}/unreblog`);
|
||||
apiRequestPost<ApiStatusJSON>(`v1/statuses/${statusId}/unreblog`);
|
||||
|
|
|
|||
34
app/javascript/mastodon/api/lists.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import {
|
||||
apiRequestPost,
|
||||
apiRequestPut,
|
||||
apiRequestGet,
|
||||
apiRequestDelete,
|
||||
} from 'mastodon/api';
|
||||
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
|
||||
import type { ApiListJSON } from 'mastodon/api_types/lists';
|
||||
|
||||
export const apiCreate = (list: Partial<ApiListJSON>) =>
|
||||
apiRequestPost<ApiListJSON>('v1/lists', list);
|
||||
|
||||
export const apiUpdate = (list: Partial<ApiListJSON>) =>
|
||||
apiRequestPut<ApiListJSON>(`v1/lists/${list.id}`, list);
|
||||
|
||||
export const apiGetLists = () => apiRequestGet<ApiListJSON[]>('v1/lists');
|
||||
|
||||
export const apiGetAccounts = (listId: string) =>
|
||||
apiRequestGet<ApiAccountJSON[]>(`v1/lists/${listId}/accounts`, {
|
||||
limit: 0,
|
||||
});
|
||||
|
||||
export const apiGetAccountLists = (accountId: string) =>
|
||||
apiRequestGet<ApiListJSON[]>(`v1/accounts/${accountId}/lists`);
|
||||
|
||||
export const apiAddAccountToList = (listId: string, accountId: string) =>
|
||||
apiRequestPost(`v1/lists/${listId}/accounts`, {
|
||||
account_ids: [accountId],
|
||||
});
|
||||
|
||||
export const apiRemoveAccountFromList = (listId: string, accountId: string) =>
|
||||
apiRequestDelete(`v1/lists/${listId}/accounts`, {
|
||||
account_ids: [accountId],
|
||||
});
|
||||
10
app/javascript/mastodon/api/polls.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { apiRequestGet, apiRequestPost } from 'mastodon/api';
|
||||
import type { ApiPollJSON } from 'mastodon/api_types/polls';
|
||||
|
||||
export const apiGetPoll = (pollId: string) =>
|
||||
apiRequestGet<ApiPollJSON>(`v1/polls/${pollId}`);
|
||||
|
||||
export const apiPollVote = (pollId: string, choices: string[]) =>
|
||||
apiRequestPost<ApiPollJSON>(`v1/polls/${pollId}/votes`, {
|
||||
choices,
|
||||
});
|
||||
16
app/javascript/mastodon/api/search.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { apiRequestGet } from 'mastodon/api';
|
||||
import type {
|
||||
ApiSearchType,
|
||||
ApiSearchResultsJSON,
|
||||
} from 'mastodon/api_types/search';
|
||||
|
||||
export const apiGetSearch = (params: {
|
||||
q: string;
|
||||
resolve?: boolean;
|
||||
type?: ApiSearchType;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) =>
|
||||
apiRequestGet<ApiSearchResultsJSON>('v2/search', {
|
||||
...params,
|
||||
});
|
||||
5
app/javascript/mastodon/api/statuses.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { apiRequestGet } from 'mastodon/api';
|
||||
import type { ApiContextJSON } from 'mastodon/api_types/statuses';
|
||||
|
||||
export const apiGetContext = (statusId: string) =>
|
||||
apiRequestGet<ApiContextJSON>(`v1/statuses/${statusId}/context`);
|
||||
8
app/javascript/mastodon/api/suggestions.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { apiRequestGet, apiRequestDelete } from 'mastodon/api';
|
||||
import type { ApiSuggestionJSON } from 'mastodon/api_types/suggestions';
|
||||
|
||||
export const apiGetSuggestions = (limit: number) =>
|
||||
apiRequestGet<ApiSuggestionJSON[]>('v2/suggestions', { limit });
|
||||
|
||||
export const apiDeleteSuggestion = (accountId: string) =>
|
||||
apiRequestDelete(`v1/suggestions/${accountId}`);
|
||||
30
app/javascript/mastodon/api/tags.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import api, { getLinks, apiRequestPost, apiRequestGet } from 'mastodon/api';
|
||||
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
|
||||
|
||||
export const apiGetTag = (tagId: string) =>
|
||||
apiRequestGet<ApiHashtagJSON>(`v1/tags/${tagId}`);
|
||||
|
||||
export const apiFollowTag = (tagId: string) =>
|
||||
apiRequestPost<ApiHashtagJSON>(`v1/tags/${tagId}/follow`);
|
||||
|
||||
export const apiUnfollowTag = (tagId: string) =>
|
||||
apiRequestPost<ApiHashtagJSON>(`v1/tags/${tagId}/unfollow`);
|
||||
|
||||
export const apiFeatureTag = (tagId: string) =>
|
||||
apiRequestPost<ApiHashtagJSON>(`v1/tags/${tagId}/feature`);
|
||||
|
||||
export const apiUnfeatureTag = (tagId: string) =>
|
||||
apiRequestPost<ApiHashtagJSON>(`v1/tags/${tagId}/unfeature`);
|
||||
|
||||
export const apiGetFollowedTags = async (url?: string, limit?: number) => {
|
||||
const response = await api().request<ApiHashtagJSON[]>({
|
||||
method: 'GET',
|
||||
url: url ?? '/api/v1/followed_tags',
|
||||
params: { limit },
|
||||
});
|
||||
|
||||
return {
|
||||
tags: response.data,
|
||||
links: getLinks(response),
|
||||
};
|
||||
};
|
||||
|
|
@ -19,7 +19,7 @@ export interface BaseApiAccountJSON {
|
|||
avatar_static: string;
|
||||
bot: boolean;
|
||||
created_at: string;
|
||||
discoverable: boolean;
|
||||
discoverable?: boolean;
|
||||
indexable: boolean;
|
||||
display_name: string;
|
||||
emojis: ApiCustomEmojiJSON[];
|
||||
|
|
@ -37,7 +37,7 @@ export interface BaseApiAccountJSON {
|
|||
roles?: ApiAccountJSON[];
|
||||
statuses_count: number;
|
||||
uri: string;
|
||||
url: string;
|
||||
url?: string;
|
||||
username: string;
|
||||
moved?: ApiAccountJSON;
|
||||
suspended?: boolean;
|
||||
|
|
@ -54,3 +54,9 @@ export interface ApiMutedAccountJSON extends BaseApiAccountJSON {
|
|||
// For now, we have the same type representing both `Account` and `MutedAccount`
|
||||
// objects, but we should refactor this in the future.
|
||||
export type ApiAccountJSON = ApiMutedAccountJSON;
|
||||
|
||||
// See app/serializers/rest/familiar_followers_serializer.rb
|
||||
export type ApiFamiliarFollowersJSON = {
|
||||
id: string;
|
||||
accounts: ApiAccountJSON[];
|
||||
}[];
|
||||
|
|
|
|||
11
app/javascript/mastodon/api_types/instance.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export interface ApiTermsOfServiceJSON {
|
||||
effective_date: string;
|
||||
effective: boolean;
|
||||
succeeded_by: string | null;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ApiPrivacyPolicyJSON {
|
||||
updated_at: string;
|
||||
content: string;
|
||||
}
|
||||
10
app/javascript/mastodon/api_types/lists.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// See app/serializers/rest/list_serializer.rb
|
||||
|
||||
export type RepliesPolicyType = 'list' | 'followed' | 'none';
|
||||
|
||||
export interface ApiListJSON {
|
||||
id: string;
|
||||
title: string;
|
||||
exclusive: boolean;
|
||||
replies_policy: RepliesPolicyType;
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ export const allNotificationTypes = [
|
|||
'admin.report',
|
||||
'moderation_warning',
|
||||
'severed_relationships',
|
||||
'annual_report',
|
||||
];
|
||||
|
||||
export type NotificationWithStatusType =
|
||||
|
|
@ -37,7 +38,8 @@ export type NotificationType =
|
|||
| 'moderation_warning'
|
||||
| 'severed_relationships'
|
||||
| 'admin.sign_up'
|
||||
| 'admin.report';
|
||||
| 'admin.report'
|
||||
| 'annual_report';
|
||||
|
||||
export interface BaseNotificationJSON {
|
||||
id: string;
|
||||
|
|
@ -130,6 +132,15 @@ interface AccountRelationshipSeveranceNotificationJSON
|
|||
event: ApiAccountRelationshipSeveranceEventJSON;
|
||||
}
|
||||
|
||||
export interface ApiAnnualReportEventJSON {
|
||||
year: string;
|
||||
}
|
||||
|
||||
interface AnnualReportNotificationGroupJSON extends BaseNotificationGroupJSON {
|
||||
type: 'annual_report';
|
||||
annual_report: ApiAnnualReportEventJSON;
|
||||
}
|
||||
|
||||
export type ApiNotificationJSON =
|
||||
| SimpleNotificationJSON
|
||||
| ReportNotificationJSON
|
||||
|
|
@ -142,7 +153,8 @@ export type ApiNotificationGroupJSON =
|
|||
| ReportNotificationGroupJSON
|
||||
| AccountRelationshipSeveranceNotificationGroupJSON
|
||||
| NotificationGroupWithStatusJSON
|
||||
| ModerationWarningNotificationGroupJSON;
|
||||
| ModerationWarningNotificationGroupJSON
|
||||
| AnnualReportNotificationGroupJSON;
|
||||
|
||||
export interface ApiNotificationGroupsResultJSON {
|
||||
accounts: ApiAccountJSON[];
|
||||
|
|
|
|||
|
|
@ -13,11 +13,11 @@ export interface ApiPollJSON {
|
|||
expired: boolean;
|
||||
multiple: boolean;
|
||||
votes_count: number;
|
||||
voters_count: number;
|
||||
voters_count: number | null;
|
||||
|
||||
options: ApiPollOptionJSON[];
|
||||
emojis: ApiCustomEmojiJSON[];
|
||||
|
||||
voted: boolean;
|
||||
own_votes: number[];
|
||||
voted?: boolean;
|
||||
own_votes?: number[];
|
||||
}
|
||||
|
|
|
|||
11
app/javascript/mastodon/api_types/search.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import type { ApiAccountJSON } from './accounts';
|
||||
import type { ApiStatusJSON } from './statuses';
|
||||
import type { ApiHashtagJSON } from './tags';
|
||||
|
||||
export type ApiSearchType = 'accounts' | 'statuses' | 'hashtags';
|
||||
|
||||
export interface ApiSearchResultsJSON {
|
||||
accounts: ApiAccountJSON[];
|
||||
statuses: ApiStatusJSON[];
|
||||
hashtags: ApiHashtagJSON[];
|
||||
}
|
||||
|
|
@ -119,3 +119,8 @@ export interface ApiStatusJSON {
|
|||
card?: ApiPreviewCardJSON;
|
||||
poll?: ApiPollJSON;
|
||||
}
|
||||
|
||||
export interface ApiContextJSON {
|
||||
ancestors: ApiStatusJSON[];
|
||||
descendants: ApiStatusJSON[];
|
||||
}
|
||||
|
|
|
|||
13
app/javascript/mastodon/api_types/suggestions.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
|
||||
|
||||
export type ApiSuggestionSourceJSON =
|
||||
| 'featured'
|
||||
| 'most_followed'
|
||||
| 'most_interactions'
|
||||
| 'similar_to_recently_followed'
|
||||
| 'friends_of_friends';
|
||||
|
||||
export interface ApiSuggestionJSON {
|
||||
sources: [ApiSuggestionSourceJSON, ...ApiSuggestionSourceJSON[]];
|
||||
account: ApiAccountJSON;
|
||||
}
|
||||
14
app/javascript/mastodon/api_types/tags.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
interface ApiHistoryJSON {
|
||||
day: string;
|
||||
accounts: string;
|
||||
uses: string;
|
||||
}
|
||||
|
||||
export interface ApiHashtagJSON {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
history: [ApiHistoryJSON, ...ApiHistoryJSON[]];
|
||||
following?: boolean;
|
||||
featuring?: boolean;
|
||||
}
|
||||
|
|
@ -96,13 +96,19 @@ export const decode83 = (str: string) => {
|
|||
return value;
|
||||
};
|
||||
|
||||
export const intToRGB = (int: number) => ({
|
||||
export interface RGB {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
export const intToRGB = (int: number): RGB => ({
|
||||
r: Math.max(0, int >> 16),
|
||||
g: Math.max(0, (int >> 8) & 255),
|
||||
b: Math.max(0, int & 255),
|
||||
});
|
||||
|
||||
export const getAverageFromBlurhash = (blurhash: string) => {
|
||||
export const getAverageFromBlurhash = (blurhash: string | null) => {
|
||||
if (!blurhash) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import Rails from '@rails/ujs';
|
||||
|
||||
export function start() {
|
||||
require.context('../images/', true, /\.(jpg|png|svg)$/);
|
||||
|
||||
try {
|
||||
Rails.start();
|
||||
} catch {
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<AutosuggestEmoji /> renders emoji with custom url 1`] = `
|
||||
exports[`<AutosuggestEmoji /> > renders emoji with custom url 1`] = `
|
||||
<div
|
||||
className="autosuggest-emoji"
|
||||
>
|
||||
|
|
@ -17,7 +17,7 @@ exports[`<AutosuggestEmoji /> renders emoji with custom url 1`] = `
|
|||
</div>
|
||||
`;
|
||||
|
||||
exports[`<AutosuggestEmoji /> renders native emoji 1`] = `
|
||||
exports[`<AutosuggestEmoji /> > renders native emoji 1`] = `
|
||||
<div
|
||||
className="autosuggest-emoji"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<Avatar /> Autoplay renders a animated avatar 1`] = `
|
||||
exports[`<Avatar /> > Autoplay > renders a animated avatar 1`] = `
|
||||
<div
|
||||
className="account__avatar account__avatar--loading"
|
||||
onMouseEnter={[Function]}
|
||||
|
|
@ -21,7 +21,7 @@ exports[`<Avatar /> Autoplay renders a animated avatar 1`] = `
|
|||
</div>
|
||||
`;
|
||||
|
||||
exports[`<Avatar /> Still renders a still avatar 1`] = `
|
||||
exports[`<Avatar /> > Still > renders a still avatar 1`] = `
|
||||
<div
|
||||
className="account__avatar account__avatar--loading"
|
||||
onMouseEnter={[Function]}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<AvatarOverlay renders a overlay avatar 1`] = `
|
||||
exports[`<AvatarOverlay > renders a overlay avatar 1`] = `
|
||||
<div
|
||||
className="account__avatar-overlay"
|
||||
onMouseEnter={[Function]}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<Button /> adds class "button-secondary" if props.secondary given 1`] = `
|
||||
exports[`<Button /> > adds class "button-secondary" if props.secondary given 1`] = `
|
||||
<button
|
||||
className="button button-secondary"
|
||||
onClick={[Function]}
|
||||
|
|
@ -8,7 +8,7 @@ exports[`<Button /> adds class "button-secondary" if props.secondary given 1`] =
|
|||
/>
|
||||
`;
|
||||
|
||||
exports[`<Button /> renders a button element 1`] = `
|
||||
exports[`<Button /> > renders a button element 1`] = `
|
||||
<button
|
||||
className="button"
|
||||
onClick={[Function]}
|
||||
|
|
@ -16,7 +16,7 @@ exports[`<Button /> renders a button element 1`] = `
|
|||
/>
|
||||
`;
|
||||
|
||||
exports[`<Button /> renders a disabled attribute if props.disabled given 1`] = `
|
||||
exports[`<Button /> > renders a disabled attribute if props.disabled given 1`] = `
|
||||
<button
|
||||
className="button"
|
||||
disabled={true}
|
||||
|
|
@ -25,7 +25,7 @@ exports[`<Button /> renders a disabled attribute if props.disabled given 1`] = `
|
|||
/>
|
||||
`;
|
||||
|
||||
exports[`<Button /> renders class="button--block" if props.block given 1`] = `
|
||||
exports[`<Button /> > renders class="button--block" if props.block given 1`] = `
|
||||
<button
|
||||
className="button button--block"
|
||||
onClick={[Function]}
|
||||
|
|
@ -33,7 +33,7 @@ exports[`<Button /> renders class="button--block" if props.block given 1`] = `
|
|||
/>
|
||||
`;
|
||||
|
||||
exports[`<Button /> renders the children 1`] = `
|
||||
exports[`<Button /> > renders the children 1`] = `
|
||||
<button
|
||||
className="button"
|
||||
onClick={[Function]}
|
||||
|
|
@ -45,7 +45,7 @@ exports[`<Button /> renders the children 1`] = `
|
|||
</button>
|
||||
`;
|
||||
|
||||
exports[`<Button /> renders the given text 1`] = `
|
||||
exports[`<Button /> > renders the given text 1`] = `
|
||||
<button
|
||||
className="button"
|
||||
onClick={[Function]}
|
||||
|
|
@ -55,7 +55,7 @@ exports[`<Button /> renders the given text 1`] = `
|
|||
</button>
|
||||
`;
|
||||
|
||||
exports[`<Button /> renders the props.text instead of children 1`] = `
|
||||
exports[`<Button /> > renders the props.text instead of children 1`] = `
|
||||
<button
|
||||
className="button"
|
||||
onClick={[Function]}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<DisplayName /> renders display name + account name 1`] = `
|
||||
exports[`<DisplayName /> > renders display name + account name 1`] = `
|
||||
<span
|
||||
className="display-name"
|
||||
onMouseEnter={[Function]}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { fromJS } from 'immutable';
|
||||
|
||||
import renderer from 'react-test-renderer';
|
||||
|
||||
import { accountDefaultValues, createAccountFromServerJSON } from '@/mastodon/models/account';
|
||||
|
||||
import { Avatar } from '../avatar';
|
||||
|
||||
describe('<Avatar />', () => {
|
||||
const account = fromJS({
|
||||
const account = createAccountFromServerJSON({
|
||||
...accountDefaultValues,
|
||||
username: 'alice',
|
||||
acct: 'alice',
|
||||
display_name: 'Alice',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import renderer from 'react-test-renderer';
|
||||
|
||||
import { render, fireEvent, screen } from 'mastodon/test_helpers';
|
||||
import { render, fireEvent, screen } from '@/testing/rendering';
|
||||
|
||||
import { Button } from '../button';
|
||||
|
||||
|
|
@ -21,7 +21,7 @@ describe('<Button />', () => {
|
|||
});
|
||||
|
||||
it('handles click events using the given handler', () => {
|
||||
const handler = jest.fn();
|
||||
const handler = vi.fn();
|
||||
render(<Button onClick={handler}>button</Button>);
|
||||
fireEvent.click(screen.getByText('button'));
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ describe('<Button />', () => {
|
|||
});
|
||||
|
||||
it('does not handle click events if props.disabled given', () => {
|
||||
const handler = jest.fn();
|
||||
const handler = vi.fn();
|
||||
render(<Button onClick={handler} disabled>button</Button>);
|
||||
fireEvent.click(screen.getByText('button'));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,181 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import { EmptyAccount } from 'mastodon/components/empty_account';
|
||||
import { ShortNumber } from 'mastodon/components/short_number';
|
||||
import { VerifiedBadge } from 'mastodon/components/verified_badge';
|
||||
|
||||
import DropdownMenuContainer from '../containers/dropdown_menu_container';
|
||||
import { me } from '../initial_state';
|
||||
|
||||
import { Avatar } from './avatar';
|
||||
import { Button } from './button';
|
||||
import { FollowersCounter } from './counters';
|
||||
import { DisplayName } from './display_name';
|
||||
import { RelativeTimestamp } from './relative_timestamp';
|
||||
|
||||
const messages = defineMessages({
|
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' },
|
||||
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
|
||||
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
|
||||
mute_notifications: { id: 'account.mute_notifications_short', defaultMessage: 'Mute notifications' },
|
||||
unmute_notifications: { id: 'account.unmute_notifications_short', defaultMessage: 'Unmute notifications' },
|
||||
mute: { id: 'account.mute_short', defaultMessage: 'Mute' },
|
||||
block: { id: 'account.block_short', defaultMessage: 'Block' },
|
||||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
});
|
||||
|
||||
const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifications, hidden, minimal, defaultAction, withBio }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const handleFollow = useCallback(() => {
|
||||
onFollow(account);
|
||||
}, [onFollow, account]);
|
||||
|
||||
const handleBlock = useCallback(() => {
|
||||
onBlock(account);
|
||||
}, [onBlock, account]);
|
||||
|
||||
const handleMute = useCallback(() => {
|
||||
onMute(account);
|
||||
}, [onMute, account]);
|
||||
|
||||
const handleMuteNotifications = useCallback(() => {
|
||||
onMuteNotifications(account, true);
|
||||
}, [onMuteNotifications, account]);
|
||||
|
||||
const handleUnmuteNotifications = useCallback(() => {
|
||||
onMuteNotifications(account, false);
|
||||
}, [onMuteNotifications, account]);
|
||||
|
||||
if (!account) {
|
||||
return <EmptyAccount size={size} minimal={minimal} />;
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
return (
|
||||
<>
|
||||
{account.get('display_name')}
|
||||
{account.get('username')}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
let buttons;
|
||||
|
||||
if (account.get('id') !== me && account.get('relationship', null) !== null) {
|
||||
const following = account.getIn(['relationship', 'following']);
|
||||
const requested = account.getIn(['relationship', 'requested']);
|
||||
const blocking = account.getIn(['relationship', 'blocking']);
|
||||
const muting = account.getIn(['relationship', 'muting']);
|
||||
|
||||
if (requested) {
|
||||
buttons = <Button text={intl.formatMessage(messages.cancel_follow_request)} onClick={handleFollow} />;
|
||||
} else if (blocking) {
|
||||
buttons = <Button text={intl.formatMessage(messages.unblock)} onClick={handleBlock} />;
|
||||
} else if (muting) {
|
||||
let menu;
|
||||
|
||||
if (account.getIn(['relationship', 'muting_notifications'])) {
|
||||
menu = [{ text: intl.formatMessage(messages.unmute_notifications), action: handleUnmuteNotifications }];
|
||||
} else {
|
||||
menu = [{ text: intl.formatMessage(messages.mute_notifications), action: handleMuteNotifications }];
|
||||
}
|
||||
|
||||
buttons = (
|
||||
<>
|
||||
<DropdownMenuContainer
|
||||
items={menu}
|
||||
icon='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
direction='right'
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
|
||||
<Button text={intl.formatMessage(messages.unmute)} onClick={handleMute} />
|
||||
</>
|
||||
);
|
||||
} else if (defaultAction === 'mute') {
|
||||
buttons = <Button text={intl.formatMessage(messages.mute)} onClick={handleMute} />;
|
||||
} else if (defaultAction === 'block') {
|
||||
buttons = <Button text={intl.formatMessage(messages.block)} onClick={handleBlock} />;
|
||||
} else if (!account.get('suspended') && !account.get('moved') || following) {
|
||||
buttons = <Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={handleFollow} />;
|
||||
}
|
||||
}
|
||||
|
||||
let muteTimeRemaining;
|
||||
|
||||
if (account.get('mute_expires_at')) {
|
||||
muteTimeRemaining = <>· <RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></>;
|
||||
}
|
||||
|
||||
let verification;
|
||||
|
||||
const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
|
||||
|
||||
if (firstVerifiedField) {
|
||||
verification = <VerifiedBadge link={firstVerifiedField.get('value')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('account', { 'account--minimal': minimal })}>
|
||||
<div className='account__wrapper'>
|
||||
<Link key={account.get('id')} className='account__display-name' title={account.get('acct')} to={`/@${account.get('acct')}`} data-hover-card-account={account.get('id')}>
|
||||
<div className='account__avatar-wrapper'>
|
||||
<Avatar account={account} size={size} />
|
||||
</div>
|
||||
|
||||
<div className='account__contents'>
|
||||
<DisplayName account={account} />
|
||||
{!minimal && (
|
||||
<div className='account__details'>
|
||||
<ShortNumber value={account.get('followers_count')} renderer={FollowersCounter} /> {verification} {muteTimeRemaining}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{!minimal && (
|
||||
<div className='account__relationship'>
|
||||
{buttons}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{withBio && (account.get('note').length > 0 ? (
|
||||
<div
|
||||
className='account__note translate'
|
||||
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
|
||||
/>
|
||||
) : (
|
||||
<div className='account__note account__note--missing'><FormattedMessage id='account.no_bio' defaultMessage='No description provided.' /></div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Account.propTypes = {
|
||||
size: PropTypes.number,
|
||||
account: ImmutablePropTypes.record,
|
||||
onFollow: PropTypes.func,
|
||||
onBlock: PropTypes.func,
|
||||
onMute: PropTypes.func,
|
||||
onMuteNotifications: PropTypes.func,
|
||||
hidden: PropTypes.bool,
|
||||
minimal: PropTypes.bool,
|
||||
defaultAction: PropTypes.string,
|
||||
withBio: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default Account;
|
||||
120
app/javascript/mastodon/components/account/account.stories.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { accountFactoryState, relationshipsFactory } from '@/testing/factories';
|
||||
|
||||
import { Account } from './index';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Account',
|
||||
component: Account,
|
||||
argTypes: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'ID of the account to display',
|
||||
},
|
||||
size: {
|
||||
type: 'number',
|
||||
description: 'Size of the avatar in pixels',
|
||||
},
|
||||
hidden: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the account is hidden or not',
|
||||
},
|
||||
minimal: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to display a minimal version of the account',
|
||||
},
|
||||
defaultAction: {
|
||||
type: 'string',
|
||||
control: 'select',
|
||||
options: ['block', 'mute'],
|
||||
description: 'Default action to take on the account',
|
||||
},
|
||||
withBio: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to display the account bio or not',
|
||||
},
|
||||
withMenu: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to display the account menu or not',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
id: '1',
|
||||
size: 46,
|
||||
hidden: false,
|
||||
minimal: false,
|
||||
defaultAction: 'mute',
|
||||
withBio: false,
|
||||
withMenu: true,
|
||||
},
|
||||
parameters: {
|
||||
state: {
|
||||
accounts: {
|
||||
'1': accountFactoryState(),
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Account>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
id: '1',
|
||||
},
|
||||
};
|
||||
|
||||
export const Hidden: Story = {
|
||||
args: {
|
||||
hidden: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Minimal: Story = {
|
||||
args: {
|
||||
minimal: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithBio: Story = {
|
||||
args: {
|
||||
withBio: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoMenu: Story = {
|
||||
args: {
|
||||
withMenu: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const Blocked: Story = {
|
||||
args: {
|
||||
defaultAction: 'block',
|
||||
},
|
||||
parameters: {
|
||||
state: {
|
||||
relationships: {
|
||||
'1': relationshipsFactory({
|
||||
blocking: true,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Muted: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
state: {
|
||||
relationships: {
|
||||
'1': relationshipsFactory({
|
||||
muting: true,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
357
app/javascript/mastodon/components/account/index.tsx
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import {
|
||||
blockAccount,
|
||||
unblockAccount,
|
||||
muteAccount,
|
||||
unmuteAccount,
|
||||
followAccountSuccess,
|
||||
unpinAccount,
|
||||
pinAccount,
|
||||
} from 'mastodon/actions/accounts';
|
||||
import { showAlertForError } from 'mastodon/actions/alerts';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { initMuteModal } from 'mastodon/actions/mutes';
|
||||
import { apiFollowAccount } from 'mastodon/api/accounts';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { FollowersCounter } from 'mastodon/components/counters';
|
||||
import { DisplayName } from 'mastodon/components/display_name';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { FollowButton } from 'mastodon/components/follow_button';
|
||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||
import { ShortNumber } from 'mastodon/components/short_number';
|
||||
import { Skeleton } from 'mastodon/components/skeleton';
|
||||
import { VerifiedBadge } from 'mastodon/components/verified_badge';
|
||||
import { useIdentity } from 'mastodon/identity_context';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import type { MenuItem } from 'mastodon/models/dropdown_menu';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
cancel_follow_request: {
|
||||
id: 'account.cancel_follow_request',
|
||||
defaultMessage: 'Withdraw follow request',
|
||||
},
|
||||
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
|
||||
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
|
||||
mute_notifications: {
|
||||
id: 'account.mute_notifications_short',
|
||||
defaultMessage: 'Mute notifications',
|
||||
},
|
||||
unmute_notifications: {
|
||||
id: 'account.unmute_notifications_short',
|
||||
defaultMessage: 'Unmute notifications',
|
||||
},
|
||||
mute: { id: 'account.mute_short', defaultMessage: 'Mute' },
|
||||
block: { id: 'account.block_short', defaultMessage: 'Block' },
|
||||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
addToLists: {
|
||||
id: 'account.add_or_remove_from_list',
|
||||
defaultMessage: 'Add or Remove from lists',
|
||||
},
|
||||
openOriginalPage: {
|
||||
id: 'account.open_original_page',
|
||||
defaultMessage: 'Open original page',
|
||||
},
|
||||
});
|
||||
|
||||
interface AccountProps {
|
||||
size?: number;
|
||||
id: string;
|
||||
hidden?: boolean;
|
||||
minimal?: boolean;
|
||||
defaultAction?: 'block' | 'mute';
|
||||
withBio?: boolean;
|
||||
withMenu?: boolean;
|
||||
}
|
||||
|
||||
export const Account: React.FC<AccountProps> = ({
|
||||
id,
|
||||
size = 46,
|
||||
hidden,
|
||||
minimal,
|
||||
defaultAction,
|
||||
withBio,
|
||||
withMenu = true,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { signedIn } = useIdentity();
|
||||
const account = useAppSelector((state) => state.accounts.get(id));
|
||||
const relationship = useAppSelector((state) => state.relationships.get(id));
|
||||
const dispatch = useAppDispatch();
|
||||
const accountUrl = account?.url;
|
||||
const isRemote = account?.acct !== account?.username;
|
||||
|
||||
const handleBlock = useCallback(() => {
|
||||
if (relationship?.blocking) {
|
||||
dispatch(unblockAccount(id));
|
||||
} else {
|
||||
dispatch(blockAccount(id));
|
||||
}
|
||||
}, [dispatch, id, relationship]);
|
||||
|
||||
const handleMute = useCallback(() => {
|
||||
if (relationship?.muting) {
|
||||
dispatch(unmuteAccount(id));
|
||||
} else {
|
||||
dispatch(initMuteModal(account));
|
||||
}
|
||||
}, [dispatch, id, account, relationship]);
|
||||
|
||||
const menu = useMemo(() => {
|
||||
let arr: MenuItem[] = [];
|
||||
|
||||
if (defaultAction === 'mute') {
|
||||
const handleMuteNotifications = () => {
|
||||
dispatch(muteAccount(id, true));
|
||||
};
|
||||
|
||||
const handleUnmuteNotifications = () => {
|
||||
dispatch(muteAccount(id, false));
|
||||
};
|
||||
|
||||
arr = [
|
||||
{
|
||||
text: intl.formatMessage(
|
||||
relationship?.muting_notifications
|
||||
? messages.unmute_notifications
|
||||
: messages.mute_notifications,
|
||||
),
|
||||
action: relationship?.muting_notifications
|
||||
? handleUnmuteNotifications
|
||||
: handleMuteNotifications,
|
||||
},
|
||||
];
|
||||
} else if (defaultAction !== 'block') {
|
||||
if (isRemote && accountUrl) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.openOriginalPage),
|
||||
href: accountUrl,
|
||||
});
|
||||
}
|
||||
|
||||
if (signedIn) {
|
||||
const handleAddToLists = () => {
|
||||
const openAddToListModal = () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'LIST_ADDER',
|
||||
modalProps: {
|
||||
accountId: id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
if (relationship?.following || relationship?.requested || id === me) {
|
||||
openAddToListModal();
|
||||
} else {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'CONFIRM_FOLLOW_TO_LIST',
|
||||
modalProps: {
|
||||
accountId: id,
|
||||
onConfirm: () => {
|
||||
apiFollowAccount(id)
|
||||
.then((relationship) => {
|
||||
dispatch(
|
||||
followAccountSuccess({
|
||||
relationship,
|
||||
alreadyFollowing: false,
|
||||
}),
|
||||
);
|
||||
openAddToListModal();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
dispatch(showAlertForError(err));
|
||||
});
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.addToLists),
|
||||
action: handleAddToLists,
|
||||
});
|
||||
|
||||
if (id !== me && (relationship?.following || relationship?.requested)) {
|
||||
const handleEndorseToggle = () => {
|
||||
if (relationship.endorsed) {
|
||||
dispatch(unpinAccount(id));
|
||||
} else {
|
||||
dispatch(pinAccount(id));
|
||||
}
|
||||
};
|
||||
arr.push({
|
||||
text: intl.formatMessage(
|
||||
// Defined in features/account_timeline/components/account_header.tsx
|
||||
relationship.endorsed
|
||||
? { id: 'account.unendorse' }
|
||||
: { id: 'account.endorse' },
|
||||
),
|
||||
action: handleEndorseToggle,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return arr;
|
||||
}, [
|
||||
dispatch,
|
||||
intl,
|
||||
id,
|
||||
accountUrl,
|
||||
relationship,
|
||||
defaultAction,
|
||||
isRemote,
|
||||
signedIn,
|
||||
]);
|
||||
|
||||
if (hidden) {
|
||||
return (
|
||||
<>
|
||||
{account?.display_name}
|
||||
{account?.username}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
let button: React.ReactNode;
|
||||
let dropdown: React.ReactNode;
|
||||
|
||||
if (menu.length > 0 && withMenu) {
|
||||
dropdown = (
|
||||
<Dropdown
|
||||
items={menu}
|
||||
icon='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (defaultAction === 'block') {
|
||||
button = (
|
||||
<Button
|
||||
text={intl.formatMessage(
|
||||
relationship?.blocking ? messages.unblock : messages.block,
|
||||
)}
|
||||
onClick={handleBlock}
|
||||
/>
|
||||
);
|
||||
} else if (defaultAction === 'mute') {
|
||||
button = (
|
||||
<Button
|
||||
text={intl.formatMessage(
|
||||
relationship?.muting ? messages.unmute : messages.mute,
|
||||
)}
|
||||
onClick={handleMute}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
button = <FollowButton accountId={id} />;
|
||||
}
|
||||
|
||||
let muteTimeRemaining: React.ReactNode;
|
||||
|
||||
if (account?.mute_expires_at) {
|
||||
muteTimeRemaining = (
|
||||
<>
|
||||
· <RelativeTimestamp timestamp={account.mute_expires_at} futureDate />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
let verification: React.ReactNode;
|
||||
|
||||
const firstVerifiedField = account?.fields.find((item) => !!item.verified_at);
|
||||
|
||||
if (firstVerifiedField) {
|
||||
verification = <VerifiedBadge link={firstVerifiedField.value} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('account', {
|
||||
'account--minimal': minimal,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={classNames('account__wrapper', {
|
||||
'account__wrapper--with-bio': account && withBio,
|
||||
})}
|
||||
>
|
||||
<div className='account__info-wrapper'>
|
||||
<Link
|
||||
className='account__display-name'
|
||||
title={account?.acct}
|
||||
to={`/@${account?.acct}`}
|
||||
data-hover-card-account={id}
|
||||
>
|
||||
<div className='account__avatar-wrapper'>
|
||||
{account ? (
|
||||
<Avatar account={account} size={size} />
|
||||
) : (
|
||||
<Skeleton width={size} height={size} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='account__contents'>
|
||||
<DisplayName account={account} />
|
||||
|
||||
{!minimal && (
|
||||
<div className='account__details'>
|
||||
{account ? (
|
||||
<>
|
||||
<ShortNumber
|
||||
value={account.followers_count}
|
||||
renderer={FollowersCounter}
|
||||
/>{' '}
|
||||
{verification} {muteTimeRemaining}
|
||||
</>
|
||||
) : (
|
||||
<Skeleton width='7ch' />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{account &&
|
||||
withBio &&
|
||||
(account.note.length > 0 ? (
|
||||
<div
|
||||
className='account__note translate'
|
||||
dangerouslySetInnerHTML={{ __html: account.note_emojified }}
|
||||
/>
|
||||
) : (
|
||||
<div className='account__note account__note--missing'>
|
||||
<FormattedMessage
|
||||
id='account.no_bio'
|
||||
defaultMessage='No description provided.'
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!minimal && (
|
||||
<div className='account__relationship'>
|
||||
{dropdown}
|
||||
{button}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,12 +1,30 @@
|
|||
import { useLinks } from 'mastodon/../hooks/useLinks';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export const AccountBio: React.FC<{
|
||||
import { useLinks } from 'mastodon/hooks/useLinks';
|
||||
|
||||
interface AccountBioProps {
|
||||
note: string;
|
||||
className: string;
|
||||
}> = ({ note, className }) => {
|
||||
const handleClick = useLinks();
|
||||
dropdownAccountId?: string;
|
||||
}
|
||||
|
||||
if (note.length === 0 || note === '<p></p>') {
|
||||
export const AccountBio: React.FC<AccountBioProps> = ({
|
||||
note,
|
||||
className,
|
||||
dropdownAccountId,
|
||||
}) => {
|
||||
const handleClick = useLinks(!!dropdownAccountId);
|
||||
const handleNodeChange = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (!dropdownAccountId || !node || node.childNodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
addDropdownToHashtags(node, dropdownAccountId);
|
||||
},
|
||||
[dropdownAccountId],
|
||||
);
|
||||
|
||||
if (note.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -15,6 +33,28 @@ export const AccountBio: React.FC<{
|
|||
className={`${className} translate`}
|
||||
dangerouslySetInnerHTML={{ __html: note }}
|
||||
onClickCapture={handleClick}
|
||||
ref={handleNodeChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function addDropdownToHashtags(node: HTMLElement | null, accountId: string) {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
for (const childNode of node.childNodes) {
|
||||
if (!(childNode instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
childNode instanceof HTMLAnchorElement &&
|
||||
(childNode.classList.contains('hashtag') ||
|
||||
childNode.innerText.startsWith('#')) &&
|
||||
!childNode.dataset.menuHashtag
|
||||
) {
|
||||
childNode.dataset.menuHashtag = accountId;
|
||||
} else if (childNode.childNodes.length > 0) {
|
||||
addDropdownToHashtags(childNode, accountId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import classNames from 'classnames';
|
||||
|
||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
import { useLinks } from 'mastodon/../hooks/useLinks';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { useLinks } from 'mastodon/hooks/useLinks';
|
||||
import type { Account } from 'mastodon/models/account';
|
||||
|
||||
export const AccountFields: React.FC<{
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ class ReportReasonSelector extends PureComponent {
|
|||
};
|
||||
|
||||
componentDidMount() {
|
||||
api(false).get('/api/v1/instance').then(res => {
|
||||
api(false).get('/api/v2/instance').then(res => {
|
||||
this.setState({
|
||||
rules: res.data.rules,
|
||||
});
|
||||
|
|
|
|||
105
app/javascript/mastodon/components/alerts_controller.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import type { IntlShape } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { dismissAlert } from 'mastodon/actions/alerts';
|
||||
import type {
|
||||
Alert,
|
||||
TranslatableString,
|
||||
TranslatableValues,
|
||||
} from 'mastodon/models/alert';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
const formatIfNeeded = (
|
||||
intl: IntlShape,
|
||||
message: TranslatableString,
|
||||
values?: TranslatableValues,
|
||||
) => {
|
||||
if (typeof message === 'object') {
|
||||
return intl.formatMessage(message, values);
|
||||
}
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
const Alert: React.FC<{
|
||||
alert: Alert;
|
||||
dismissAfter: number;
|
||||
}> = ({
|
||||
alert: { key, title, message, values, action, onClick },
|
||||
dismissAfter,
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const [active, setActive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const setActiveTimeout = setTimeout(() => {
|
||||
setActive(true);
|
||||
}, 1);
|
||||
|
||||
return () => {
|
||||
clearTimeout(setActiveTimeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const dismissTimeout = setTimeout(() => {
|
||||
setActive(false);
|
||||
|
||||
// Allow CSS transition to finish before removing from the DOM
|
||||
setTimeout(() => {
|
||||
dispatch(dismissAlert({ key }));
|
||||
}, 500);
|
||||
}, dismissAfter);
|
||||
|
||||
return () => {
|
||||
clearTimeout(dismissTimeout);
|
||||
};
|
||||
}, [dispatch, setActive, key, dismissAfter]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('notification-bar', {
|
||||
'notification-bar-active': active,
|
||||
})}
|
||||
>
|
||||
<div className='notification-bar-wrapper'>
|
||||
{title && (
|
||||
<span className='notification-bar-title'>
|
||||
{formatIfNeeded(intl, title, values)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className='notification-bar-message'>
|
||||
{formatIfNeeded(intl, message, values)}
|
||||
</span>
|
||||
|
||||
{action && (
|
||||
<button className='notification-bar-action' onClick={onClick}>
|
||||
{formatIfNeeded(intl, action, values)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AlertsController: React.FC = () => {
|
||||
const alerts = useAppSelector((state) => state.alerts);
|
||||
|
||||
if (alerts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='notification-list'>
|
||||
{alerts.map((alert, idx) => (
|
||||
<Alert key={alert.key} alert={alert} dismissAfter={5000 + idx * 1000} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useState, useCallback, useRef, useId } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
|
|
@ -8,12 +8,15 @@ import type {
|
|||
UsePopperOptions,
|
||||
} from 'react-overlays/esm/usePopper';
|
||||
|
||||
import { useSelectableClick } from 'mastodon/hooks/useSelectableClick';
|
||||
|
||||
const offset = [0, 4] as OffsetValue;
|
||||
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
|
||||
|
||||
export const AltTextBadge: React.FC<{
|
||||
description: string;
|
||||
}> = ({ description }) => {
|
||||
const accessibilityId = useId();
|
||||
const anchorRef = useRef<HTMLButtonElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
|
|
@ -25,12 +28,17 @@ export const AltTextBadge: React.FC<{
|
|||
setOpen(false);
|
||||
}, [setOpen]);
|
||||
|
||||
const [handleMouseDown, handleMouseUp] = useSelectableClick(handleClose);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type='button'
|
||||
ref={anchorRef}
|
||||
className='media-gallery__alt__label'
|
||||
onClick={handleClick}
|
||||
aria-expanded={open}
|
||||
aria-controls={accessibilityId}
|
||||
>
|
||||
ALT
|
||||
</button>
|
||||
|
|
@ -47,9 +55,12 @@ export const AltTextBadge: React.FC<{
|
|||
>
|
||||
{({ props }) => (
|
||||
<div {...props} className='hover-card-controller'>
|
||||
<div
|
||||
<div // eslint-disable-line jsx-a11y/no-noninteractive-element-interactions
|
||||
className='media-gallery__alt__popover dropdown-animation'
|
||||
role='tooltip'
|
||||
role='region'
|
||||
id={accessibilityId}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { TransitionMotion, spring } from 'react-motion';
|
||||
import { animated, useSpring, config } from '@react-spring/web';
|
||||
|
||||
import { reduceMotion } from '../initial_state';
|
||||
|
||||
|
|
@ -11,53 +11,49 @@ interface Props {
|
|||
}
|
||||
export const AnimatedNumber: React.FC<Props> = ({ value }) => {
|
||||
const [previousValue, setPreviousValue] = useState(value);
|
||||
const [direction, setDirection] = useState<1 | -1>(1);
|
||||
const direction = value > previousValue ? -1 : 1;
|
||||
|
||||
if (previousValue !== value) {
|
||||
setPreviousValue(value);
|
||||
setDirection(value > previousValue ? 1 : -1);
|
||||
}
|
||||
|
||||
const willEnter = useCallback(() => ({ y: -1 * direction }), [direction]);
|
||||
const willLeave = useCallback(
|
||||
() => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }),
|
||||
[direction],
|
||||
const [styles, api] = useSpring(
|
||||
() => ({
|
||||
from: { transform: `translateY(${100 * direction}%)` },
|
||||
to: { transform: 'translateY(0%)' },
|
||||
onRest() {
|
||||
setPreviousValue(value);
|
||||
},
|
||||
config: { ...config.gentle, duration: 200 },
|
||||
immediate: true, // This ensures that the animation is not played when the component is first rendered
|
||||
}),
|
||||
[value, previousValue],
|
||||
);
|
||||
|
||||
// When the value changes, start the animation
|
||||
useEffect(() => {
|
||||
if (value !== previousValue) {
|
||||
void api.start({ reset: true });
|
||||
}
|
||||
}, [api, previousValue, value]);
|
||||
|
||||
if (reduceMotion) {
|
||||
return <ShortNumber value={value} />;
|
||||
}
|
||||
|
||||
const styles = [
|
||||
{
|
||||
key: `${value}`,
|
||||
data: value,
|
||||
style: { y: spring(0, { damping: 35, stiffness: 400 }) },
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<TransitionMotion
|
||||
styles={styles}
|
||||
willEnter={willEnter}
|
||||
willLeave={willLeave}
|
||||
>
|
||||
{(items) => (
|
||||
<span className='animated-number'>
|
||||
{items.map(({ key, data, style }) => (
|
||||
<span
|
||||
key={key}
|
||||
style={{
|
||||
position:
|
||||
direction * (style.y ?? 0) > 0 ? 'absolute' : 'static',
|
||||
transform: `translateY(${(style.y ?? 0) * 100}%)`,
|
||||
}}
|
||||
>
|
||||
<ShortNumber value={data as number} />
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className='animated-number'>
|
||||
<animated.span style={styles}>
|
||||
<ShortNumber value={value} />
|
||||
</animated.span>
|
||||
{value !== previousValue && (
|
||||
<animated.span
|
||||
style={{
|
||||
...styles,
|
||||
position: 'absolute',
|
||||
top: `${-100 * direction}%`, // Adds extra space on top of translateY
|
||||
}}
|
||||
role='presentation'
|
||||
>
|
||||
<ShortNumber value={previousValue} />
|
||||
</animated.span>
|
||||
)}
|
||||
</TransitionMotion>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export default class AttachmentList extends ImmutablePureComponent {
|
|||
|
||||
return (
|
||||
<li key={attachment.get('id')}>
|
||||
<a href={displayUrl} target='_blank' rel='noopener noreferrer'>
|
||||
<a href={displayUrl} target='_blank' rel='noopener'>
|
||||
{compact && <Icon id='link' icon={LinkIcon} />}
|
||||
{compact && ' ' }
|
||||
{displayUrl ? filename(displayUrl) : <FormattedMessage id='attachments_list.unprocessed' defaultMessage='(unprocessed)' />}
|
||||
|
|
|
|||
|
|
@ -162,6 +162,14 @@ const AutosuggestTextarea = forwardRef(({
|
|||
}
|
||||
}, [suggestions, textareaRef, setSuggestionsHidden]);
|
||||
|
||||
// Hack to force Firefox to change language in autocorrect
|
||||
useEffect(() => {
|
||||
if (lang && textareaRef.current && textareaRef.current === document.activeElement) {
|
||||
textareaRef.current.blur();
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}, [lang]);
|
||||
|
||||
const renderSuggestion = (suggestion, i) => {
|
||||
let inner, key;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,24 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useHovering } from 'mastodon/../hooks/useHovering';
|
||||
import { useHovering } from 'mastodon/hooks/useHovering';
|
||||
import { autoPlayGif } from 'mastodon/initial_state';
|
||||
import type { Account } from 'mastodon/models/account';
|
||||
|
||||
interface Props {
|
||||
account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there
|
||||
size: number;
|
||||
account:
|
||||
| Pick<Account, 'id' | 'acct' | 'avatar' | 'avatar_static'>
|
||||
| undefined; // FIXME: remove `undefined` once we know for sure its always there
|
||||
size?: number;
|
||||
style?: React.CSSProperties;
|
||||
inline?: boolean;
|
||||
animate?: boolean;
|
||||
withLink?: boolean;
|
||||
counter?: number | string;
|
||||
counterBorderColor?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Avatar: React.FC<Props> = ({
|
||||
|
|
@ -21,7 +26,9 @@ export const Avatar: React.FC<Props> = ({
|
|||
animate = autoPlayGif,
|
||||
size = 20,
|
||||
inline = false,
|
||||
withLink = false,
|
||||
style: styleFromParent,
|
||||
className,
|
||||
counter,
|
||||
counterBorderColor,
|
||||
}) => {
|
||||
|
|
@ -35,10 +42,7 @@ export const Avatar: React.FC<Props> = ({
|
|||
height: `${size}px`,
|
||||
};
|
||||
|
||||
const src =
|
||||
hovering || animate
|
||||
? account?.get('avatar')
|
||||
: account?.get('avatar_static');
|
||||
const src = hovering || animate ? account?.avatar : account?.avatar_static;
|
||||
|
||||
const handleLoad = useCallback(() => {
|
||||
setLoading(false);
|
||||
|
|
@ -48,9 +52,9 @@ export const Avatar: React.FC<Props> = ({
|
|||
setError(true);
|
||||
}, [setError]);
|
||||
|
||||
return (
|
||||
const avatar = (
|
||||
<div
|
||||
className={classNames('account__avatar', {
|
||||
className={classNames(className, 'account__avatar', {
|
||||
'account__avatar--inline': inline,
|
||||
'account__avatar--loading': loading,
|
||||
})}
|
||||
|
|
@ -72,4 +76,18 @@ export const Avatar: React.FC<Props> = ({
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (withLink) {
|
||||
return (
|
||||
<Link
|
||||
to={`/@${account?.acct}`}
|
||||
title={`@${account?.acct}`}
|
||||
data-hover-card-account={account?.id}
|
||||
>
|
||||
{avatar}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return avatar;
|
||||
};
|
||||
|
|
|
|||