commit
a45250ac96
16
.github/workflows/main2prod.yml
vendored
Normal file
16
.github/workflows/main2prod.yml
vendored
Normal file
|
@ -0,0 +1,16 @@
|
|||
name: Pull Request to `main` from `production`
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
auto-pull-request:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: vsoch/pull-request-action@master
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PULL_REQUEST_FROM_BRANCH: 'main'
|
||||
PULL_REQUEST_BRANCH: 'production'
|
|
@ -3,7 +3,13 @@
|
|||
"useTabs": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"importOrder": [".css$", "<THIRD_PARTY_MODULES>", "^../", "^[./]"],
|
||||
"importOrder": [
|
||||
"index.css$",
|
||||
".css$",
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"^../",
|
||||
"^[./]"
|
||||
],
|
||||
"importOrderSeparation": true,
|
||||
"importOrderSortSpecifiers": true,
|
||||
"importOrderGroupNamespaceSpecifiers": true,
|
||||
|
|
14
compose/index.html
Normal file
14
compose/index.html
Normal file
|
@ -0,0 +1,14 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Compose / Phanpy</title>
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app-standalone"></div>
|
||||
<script type="module" src="/src/compose.jsx"></script>
|
||||
</body>
|
||||
</html>
|
Binary file not shown.
|
@ -3,6 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Phanpy</title>
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
|
|
1129
package-lock.json
generated
1129
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -14,7 +14,7 @@
|
|||
"fast-blurhash": "~1.1.2",
|
||||
"history": "~5.3.0",
|
||||
"iconify-icon": "~1.0.2",
|
||||
"masto": "~4.9.1",
|
||||
"masto": "~4.10.0",
|
||||
"mem": "~9.0.2",
|
||||
"preact": "~10.11.3",
|
||||
"preact-router": "~4.1.0",
|
||||
|
@ -23,12 +23,12 @@
|
|||
"valtio": "~1.7.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "~2.4.0",
|
||||
"@preact/preset-vite": "~2.5.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "~4.0.0",
|
||||
"autoprefixer": "~10.4.13",
|
||||
"postcss": "~8.4.19",
|
||||
"postcss": "~8.4.20",
|
||||
"postcss-dark-theme-class": "~0.7.3",
|
||||
"vite": "3.2.5"
|
||||
"vite": "4.0.1"
|
||||
},
|
||||
"postcss": {
|
||||
"plugins": {
|
||||
|
|
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
|
@ -4,7 +4,7 @@ body {
|
|||
padding: 0;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
overflow: hidden;
|
||||
/* overflow: hidden; */
|
||||
}
|
||||
|
||||
#app {
|
||||
|
|
32
src/app.jsx
32
src/app.jsx
|
@ -18,12 +18,13 @@ import Settings from './pages/settings';
|
|||
import Status from './pages/status';
|
||||
import Welcome from './pages/welcome';
|
||||
import { getAccessToken } from './utils/auth';
|
||||
import openCompose from './utils/open-compose';
|
||||
import states from './utils/states';
|
||||
import store from './utils/store';
|
||||
|
||||
const { VITE_CLIENT_NAME: CLIENT_NAME } = import.meta.env;
|
||||
|
||||
window._STATES = states;
|
||||
window.__STATES__ = states;
|
||||
|
||||
async function startStream() {
|
||||
const stream = await masto.stream.streamUser();
|
||||
|
@ -53,11 +54,10 @@ async function startStream() {
|
|||
states.statuses.set(status.reblog.id, status.reblog);
|
||||
}
|
||||
});
|
||||
// Uncomment this once this bug is fixed: https://github.com/neet/masto.js/issues/750
|
||||
// stream.on('delete', (statusID) => {
|
||||
// console.log('DELETE', statusID);
|
||||
// states.statuses.delete(statusID);
|
||||
// });
|
||||
stream.on('delete', (statusID) => {
|
||||
console.log('DELETE', statusID);
|
||||
states.statuses.delete(statusID);
|
||||
});
|
||||
stream.on('notification', (notification) => {
|
||||
console.log('NOTIFICATION', notification);
|
||||
|
||||
|
@ -224,7 +224,17 @@ export function App() {
|
|||
<button
|
||||
type="button"
|
||||
id="compose-button"
|
||||
onClick={() => (states.showCompose = true)}
|
||||
onClick={(e) => {
|
||||
if (e.shiftKey) {
|
||||
const newWin = openCompose();
|
||||
if (!newWin) {
|
||||
alert('Looks like your browser is blocking popups.');
|
||||
states.showCompose = true;
|
||||
}
|
||||
} else {
|
||||
states.showCompose = true;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon icon="quill" size="xxl" alt="Compose" />
|
||||
</button>
|
||||
|
@ -266,10 +276,12 @@ export function App() {
|
|||
? snapStates.showCompose.replyToStatus
|
||||
: null
|
||||
}
|
||||
editStatus={snapStates.showCompose?.editStatus || null}
|
||||
onClose={(result) => {
|
||||
editStatus={states.showCompose?.editStatus || null}
|
||||
draftStatus={states.showCompose?.draftStatus || null}
|
||||
onClose={(results) => {
|
||||
const { newStatus } = results || {};
|
||||
states.showCompose = false;
|
||||
if (result) {
|
||||
if (newStatus) {
|
||||
states.reloadStatusPage++;
|
||||
}
|
||||
}}
|
||||
|
|
|
@ -17,6 +17,7 @@ export default ({ url, size, alt = '' }) => {
|
|||
width: size,
|
||||
height: size,
|
||||
}}
|
||||
title={alt}
|
||||
>
|
||||
{!!url && (
|
||||
<img src={url} width={size} height={size} alt={alt} loading="lazy" />
|
||||
|
|
|
@ -6,6 +6,10 @@
|
|||
max-height: 100vh;
|
||||
overflow: auto;
|
||||
}
|
||||
#compose-container.standalone {
|
||||
max-height: none;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
#compose-container .compose-top {
|
||||
text-align: right;
|
||||
|
@ -19,11 +23,6 @@
|
|||
z-index: 100;
|
||||
}
|
||||
|
||||
#compose-container .close-button {
|
||||
padding: 6px;
|
||||
color: var(--text-insignificant-color);
|
||||
}
|
||||
|
||||
#compose-container textarea {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
|
@ -42,27 +41,54 @@
|
|||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
#compose-container .reply-to {
|
||||
#compose-container .status-preview {
|
||||
border-radius: 8px 8px 0 0;
|
||||
max-height: 160px;
|
||||
pointer-events: none;
|
||||
filter: saturate(0.25) opacity(0.75);
|
||||
background-color: var(--bg-blur-color);
|
||||
background-color: var(--bg-color);
|
||||
margin: 0 12px;
|
||||
border: 1px solid var(--outline-color);
|
||||
border-bottom: 0;
|
||||
/* box-shadow: 0 0 12px var(--divider-color); */
|
||||
/* mask-image: linear-gradient(rgba(0, 0, 0, 1), rgba(0, 0, 0, 1) 90%, transparent); */
|
||||
animation: appear-up 1s ease-in-out;
|
||||
overflow: auto;
|
||||
}
|
||||
@keyframes appear-down {
|
||||
0% {
|
||||
transform: translateY(-2em);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
#compose-container.standalone .status-preview * {
|
||||
/*
|
||||
For standalone mode (new window), prevent interacting with the status preview for now
|
||||
*/
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#compose-container .status-preview-legend {
|
||||
pointer-events: none;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
padding: 8px;
|
||||
font-size: 80%;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
color: var(--text-insignificant-color);
|
||||
background-color: var(--bg-blur-color);
|
||||
/* background-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
var(--bg-faded-color)
|
||||
); */
|
||||
border-top: 1px solid var(--outline-color);
|
||||
backdrop-filter: blur(8px);
|
||||
text-shadow: 0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color),
|
||||
0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color),
|
||||
0 1px 10px var(--bg-color);
|
||||
}
|
||||
#_compose-container .status-preview-legend.reply-to {
|
||||
color: var(--reply-to-color);
|
||||
background-color: var(--reply-to-faded-color);
|
||||
/* background-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
var(--reply-to-faded-color)
|
||||
); */
|
||||
}
|
||||
|
||||
#compose-container form {
|
||||
border-radius: 8px;
|
||||
padding: 4px 12px;
|
||||
|
@ -70,8 +96,7 @@
|
|||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
#compose-container .reply-to ~ form {
|
||||
animation: appear-down 1s ease-in-out;
|
||||
#compose-container .status-preview ~ form {
|
||||
box-shadow: 0 -12px 12px -12px var(--divider-color);
|
||||
}
|
||||
|
||||
|
@ -89,8 +114,8 @@
|
|||
min-width: 0;
|
||||
}
|
||||
#compose-container .toolbar-button {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
color: var(--text-color);
|
||||
background-color: var(--bg-faded-color);
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
|
@ -102,6 +127,7 @@
|
|||
position: relative;
|
||||
white-space: nowrap;
|
||||
border: 2px solid transparent;
|
||||
vertical-align: middle;
|
||||
}
|
||||
#compose-container .toolbar-button > * {
|
||||
vertical-align: middle;
|
||||
|
@ -109,10 +135,12 @@
|
|||
outline: 0;
|
||||
}
|
||||
#compose-container .toolbar-button:has([disabled]) {
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
background-color: var(--bg-faded-color);
|
||||
opacity: 0.5;
|
||||
}
|
||||
#compose-container .toolbar-button:has([disabled]) > * {
|
||||
filter: opacity(0.3);
|
||||
/* filter: opacity(0.5); */
|
||||
}
|
||||
#compose-container
|
||||
.toolbar-button:not(.show-field)
|
||||
|
@ -136,10 +164,12 @@
|
|||
right: 0;
|
||||
left: auto !important;
|
||||
}
|
||||
#compose-container .toolbar-button:hover {
|
||||
#compose-container .toolbar-button:not(:disabled):hover {
|
||||
cursor: pointer;
|
||||
filter: none;
|
||||
border-color: var(--divider-color);
|
||||
}
|
||||
#compose-container .toolbar-button:active {
|
||||
#compose-container .toolbar-button:not(:disabled):active {
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
|
||||
|
@ -251,3 +281,80 @@
|
|||
color: var(--green-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
#compose-container form .poll {
|
||||
background-color: var(--bg-faded-color);
|
||||
border-radius: 8px;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
#compose-container .poll-choices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
#compose-container .poll-choice {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: stretch;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
#compose-container .poll-choice input {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#compose-container .poll-button {
|
||||
border: 2px solid var(--outline-color);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
line-height: 0;
|
||||
overflow: hidden;
|
||||
transition: border-radius 1s ease-out;
|
||||
font-size: 14px;
|
||||
}
|
||||
#compose-container .multiple .poll-button {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#compose-container .poll-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
font-size: 90%;
|
||||
border-top: 1px solid var(--outline-color);
|
||||
padding: 8px;
|
||||
}
|
||||
#compose-container .poll-toolbar select {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
#compose-container .multiple-choices {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
border-left: 1px solid var(--outline-color);
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
#compose-container .expires-in {
|
||||
flex-grow: 1;
|
||||
border-left: 1px solid var(--outline-color);
|
||||
padding-left: 8px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
#compose-container .remove-poll-button {
|
||||
width: 100%;
|
||||
color: var(--red-color);
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|||
import stringLength from 'string-length';
|
||||
|
||||
import emojifyText from '../utils/emojify-text';
|
||||
import openCompose from '../utils/open-compose';
|
||||
import store from '../utils/store';
|
||||
import visibilityIconsMap from '../utils/visibility-icons-map';
|
||||
|
||||
|
@ -17,7 +18,32 @@ import Status from './status';
|
|||
- Max character limit includes BOTH status text and Content Warning text
|
||||
*/
|
||||
|
||||
export default ({ onClose, replyToStatus, editStatus }) => {
|
||||
const expiryOptions = {
|
||||
'5 minutes': 5 * 60,
|
||||
'30 minutes': 30 * 60,
|
||||
'1 hour': 60 * 60,
|
||||
'6 hours': 6 * 60 * 60,
|
||||
'1 day': 24 * 60 * 60,
|
||||
'3 days': 3 * 24 * 60 * 60,
|
||||
'7 days': 7 * 24 * 60 * 60,
|
||||
};
|
||||
const expirySeconds = Object.values(expiryOptions);
|
||||
const oneDay = 24 * 60 * 60;
|
||||
|
||||
const expiresInFromExpiresAt = (expiresAt) => {
|
||||
if (!expiresAt) return oneDay;
|
||||
const delta = (new Date(expiresAt).getTime() - Date.now()) / 1000;
|
||||
return expirySeconds.find((s) => s >= delta) || oneDay;
|
||||
};
|
||||
|
||||
function Compose({
|
||||
onClose,
|
||||
replyToStatus,
|
||||
editStatus,
|
||||
draftStatus,
|
||||
standalone,
|
||||
hasOpener,
|
||||
}) {
|
||||
const [uiState, setUIState] = useState('default');
|
||||
|
||||
const accounts = store.local.getJSON('accounts');
|
||||
|
@ -50,29 +76,55 @@ export default ({ onClose, replyToStatus, editStatus }) => {
|
|||
} = configuration;
|
||||
|
||||
const textareaRef = useRef();
|
||||
|
||||
const [visibility, setVisibility] = useState(
|
||||
replyToStatus?.visibility || 'public',
|
||||
);
|
||||
const [sensitive, setSensitive] = useState(replyToStatus?.sensitive || false);
|
||||
const spoilerTextRef = useRef();
|
||||
const [visibility, setVisibility] = useState('public');
|
||||
const [sensitive, setSensitive] = useState(false);
|
||||
const [mediaAttachments, setMediaAttachments] = useState([]);
|
||||
const [poll, setPoll] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let timer = setTimeout(() => {
|
||||
const spoilerText = replyToStatus?.spoilerText;
|
||||
if (replyToStatus) {
|
||||
const { spoilerText, visibility, sensitive } = replyToStatus;
|
||||
if (spoilerText && spoilerTextRef.current) {
|
||||
spoilerTextRef.current.value = spoilerText;
|
||||
spoilerTextRef.current.focus();
|
||||
} else {
|
||||
textareaRef.current?.focus();
|
||||
textareaRef.current.focus();
|
||||
if (replyToStatus.account.id !== currentAccount) {
|
||||
textareaRef.current.value = `@${replyToStatus.account.acct} `;
|
||||
}
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (editStatus) {
|
||||
const { visibility, sensitive, mediaAttachments } = editStatus;
|
||||
}
|
||||
setVisibility(visibility);
|
||||
setSensitive(sensitive);
|
||||
}
|
||||
if (draftStatus) {
|
||||
const {
|
||||
status,
|
||||
spoilerText,
|
||||
visibility,
|
||||
sensitive,
|
||||
poll,
|
||||
mediaAttachments,
|
||||
} = draftStatus;
|
||||
const composablePoll = !!poll?.options && {
|
||||
...poll,
|
||||
options: poll.options.map((o) => o?.title || o),
|
||||
expiresIn: poll?.expiresIn || expiresInFromExpiresAt(poll.expiresAt),
|
||||
};
|
||||
textareaRef.current.value = status;
|
||||
textareaRef.current.dispatchEvent(new Event('input'));
|
||||
spoilerTextRef.current.value = spoilerText;
|
||||
setVisibility(visibility);
|
||||
setSensitive(sensitive);
|
||||
setPoll(composablePoll);
|
||||
setMediaAttachments(mediaAttachments);
|
||||
} else if (editStatus) {
|
||||
const { visibility, sensitive, poll, mediaAttachments } = editStatus;
|
||||
const composablePoll = !!poll?.options && {
|
||||
...poll,
|
||||
options: poll.options.map((o) => o?.title || o),
|
||||
expiresIn: poll?.expiresIn || expiresInFromExpiresAt(poll.expiresAt),
|
||||
};
|
||||
setUIState('loading');
|
||||
(async () => {
|
||||
try {
|
||||
|
@ -80,10 +132,12 @@ export default ({ onClose, replyToStatus, editStatus }) => {
|
|||
console.log({ statusSource });
|
||||
const { text, spoilerText } = statusSource;
|
||||
textareaRef.current.value = text;
|
||||
textareaRef.current.dispatchEvent(new Event('input'));
|
||||
textareaRef.current.dataset.source = text;
|
||||
spoilerTextRef.current.value = spoilerText;
|
||||
setVisibility(visibility);
|
||||
setSensitive(sensitive);
|
||||
setPoll(composablePoll);
|
||||
setMediaAttachments(mediaAttachments);
|
||||
setUIState('default');
|
||||
} catch (e) {
|
||||
|
@ -93,14 +147,14 @@ export default ({ onClose, replyToStatus, editStatus }) => {
|
|||
}
|
||||
})();
|
||||
}
|
||||
}, [editStatus]);
|
||||
}, [draftStatus, editStatus, replyToStatus]);
|
||||
|
||||
const textExpanderRef = useRef();
|
||||
const textExpanderTextRef = useRef('');
|
||||
useEffect(() => {
|
||||
if (textExpanderRef.current) {
|
||||
const handleChange = (e) => {
|
||||
console.log('text-expander-change', e);
|
||||
// console.log('text-expander-change', e);
|
||||
const { key, provide, text } = e.detail;
|
||||
textExpanderTextRef.current = text;
|
||||
if (text === '') {
|
||||
|
@ -185,20 +239,60 @@ export default ({ onClose, replyToStatus, editStatus }) => {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const [mediaAttachments, setMediaAttachments] = useState([]);
|
||||
|
||||
const formRef = useRef();
|
||||
|
||||
const beforeUnloadCopy =
|
||||
'You have unsaved changes. Are you sure you want to discard this post?';
|
||||
const canClose = () => {
|
||||
// check for status or mediaAttachments
|
||||
const { value, dataset } = textareaRef.current;
|
||||
const containNonIDMediaAttachments =
|
||||
mediaAttachments.length > 0 &&
|
||||
mediaAttachments.some((media) => !media.id);
|
||||
|
||||
if ((value && value !== dataset?.source) || containNonIDMediaAttachments) {
|
||||
// check for status and media attachments
|
||||
const hasMediaAttachments = mediaAttachments.length > 0;
|
||||
if (!value && !hasMediaAttachments) {
|
||||
console.log('canClose', { value, mediaAttachments });
|
||||
return true;
|
||||
}
|
||||
|
||||
// check if all media attachments have IDs
|
||||
const hasIDMediaAttachments =
|
||||
mediaAttachments.length > 0 &&
|
||||
mediaAttachments.every((media) => media.id);
|
||||
if (hasIDMediaAttachments) {
|
||||
console.log('canClose', { hasIDMediaAttachments });
|
||||
return true;
|
||||
}
|
||||
|
||||
// check if status contains only "@acct", if replying
|
||||
const isSelf = replyToStatus?.account.id === currentAccount;
|
||||
const hasOnlyAcct =
|
||||
replyToStatus && value.trim() === `@${replyToStatus.account.acct}`;
|
||||
if (!isSelf && hasOnlyAcct) {
|
||||
console.log('canClose', { isSelf, hasOnlyAcct });
|
||||
return true;
|
||||
}
|
||||
|
||||
// check if status is same with source
|
||||
const sameWithSource = value === dataset?.source;
|
||||
if (sameWithSource) {
|
||||
console.log('canClose', { sameWithSource });
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log('canClose', {
|
||||
value,
|
||||
hasMediaAttachments,
|
||||
hasIDMediaAttachments,
|
||||
poll,
|
||||
isSelf,
|
||||
hasOnlyAcct,
|
||||
sameWithSource,
|
||||
});
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const confirmClose = () => {
|
||||
if (!canClose()) {
|
||||
const yes = confirm(beforeUnloadCopy);
|
||||
return yes;
|
||||
}
|
||||
|
@ -223,7 +317,7 @@ export default ({ onClose, replyToStatus, editStatus }) => {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<div id="compose-container">
|
||||
<div id="compose-container" class={standalone ? 'standalone' : ''}>
|
||||
<div class="compose-top">
|
||||
{currentAccountInfo?.avatarStatic && (
|
||||
<Avatar
|
||||
|
@ -232,21 +326,128 @@ export default ({ onClose, replyToStatus, editStatus }) => {
|
|||
alt={currentAccountInfo.username}
|
||||
/>
|
||||
)}
|
||||
{!standalone ? (
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
class="light"
|
||||
onClick={() => {
|
||||
// If there are non-ID media attachments (not yet uploaded), show confirmation dialog because they are not going to be passed to the new window
|
||||
const containNonIDMediaAttachments =
|
||||
mediaAttachments.length > 0 &&
|
||||
mediaAttachments.some((media) => !media.id);
|
||||
if (containNonIDMediaAttachments) {
|
||||
const yes = confirm(
|
||||
'You have media attachments that are not yet uploaded. Opening a new window will discard them and you will need to re-attach them. Are you sure you want to continue?',
|
||||
);
|
||||
if (!yes) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const mediaAttachmentsWithIDs = mediaAttachments.filter(
|
||||
(media) => media.id,
|
||||
);
|
||||
|
||||
const newWin = openCompose({
|
||||
editStatus,
|
||||
replyToStatus,
|
||||
draftStatus: {
|
||||
status: textareaRef.current.value,
|
||||
spoilerText: spoilerTextRef.current.value,
|
||||
visibility,
|
||||
sensitive,
|
||||
poll,
|
||||
mediaAttachments: mediaAttachmentsWithIDs,
|
||||
},
|
||||
});
|
||||
|
||||
if (!newWin) {
|
||||
alert('Looks like your browser is blocking popups.');
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Icon icon="popout" alt="Pop out" />
|
||||
</button>{' '}
|
||||
<button
|
||||
type="button"
|
||||
class="light close-button"
|
||||
onClick={() => {
|
||||
if (canClose()) {
|
||||
if (confirmClose()) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
hasOpener && (
|
||||
<button
|
||||
type="button"
|
||||
class="light"
|
||||
onClick={() => {
|
||||
// If there are non-ID media attachments (not yet uploaded), show confirmation dialog because they are not going to be passed to the new window
|
||||
const containNonIDMediaAttachments =
|
||||
mediaAttachments.length > 0 &&
|
||||
mediaAttachments.some((media) => !media.id);
|
||||
if (containNonIDMediaAttachments) {
|
||||
const yes = confirm(
|
||||
'You have media attachments that are not yet uploaded. Opening a new window will discard them and you will need to re-attach them. Are you sure you want to continue?',
|
||||
);
|
||||
if (!yes) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!window.opener) {
|
||||
alert('Looks like you closed the parent window.');
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaAttachmentsWithIDs = mediaAttachments.filter(
|
||||
(media) => media.id,
|
||||
);
|
||||
|
||||
onClose({
|
||||
fn: () => {
|
||||
window.opener.__STATES__.showCompose = {
|
||||
editStatus,
|
||||
replyToStatus,
|
||||
draftStatus: {
|
||||
status: textareaRef.current.value,
|
||||
spoilerText: spoilerTextRef.current.value,
|
||||
visibility,
|
||||
sensitive,
|
||||
poll,
|
||||
mediaAttachments: mediaAttachmentsWithIDs,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon icon="popin" alt="Pop in" />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{!!replyToStatus && (
|
||||
<div class="reply-to">
|
||||
<div class="status-preview">
|
||||
<Status status={replyToStatus} size="s" />
|
||||
<div class="status-preview-legend reply-to">
|
||||
Replying to @
|
||||
{replyToStatus.account.acct || replyToStatus.account.username}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!!editStatus && (
|
||||
<div class="status-preview">
|
||||
<Status status={editStatus} size="s" />
|
||||
<div class="status-preview-legend">Editing source status</div>
|
||||
</div>
|
||||
)}
|
||||
<form
|
||||
|
@ -280,6 +481,16 @@ export default ({ onClose, replyToStatus, editStatus }) => {
|
|||
);
|
||||
return;
|
||||
}
|
||||
if (poll) {
|
||||
if (poll.options.length < 2) {
|
||||
alert('Poll must have at least 2 options');
|
||||
return;
|
||||
}
|
||||
if (poll.options.some((option) => option === '')) {
|
||||
alert('Some poll choices are empty');
|
||||
return;
|
||||
}
|
||||
}
|
||||
// TODO: check for URLs and use `charactersReservedPerUrl` to calculate max characters
|
||||
|
||||
// Post-cleanup
|
||||
|
@ -293,8 +504,7 @@ export default ({ onClose, replyToStatus, editStatus }) => {
|
|||
if (mediaAttachments.length > 0) {
|
||||
// Upload media attachments first
|
||||
const mediaPromises = mediaAttachments.map((attachment) => {
|
||||
const { file, description, sourceDescription, id } =
|
||||
attachment;
|
||||
const { file, description, id } = attachment;
|
||||
console.log('UPLOADING', attachment);
|
||||
if (id) {
|
||||
// If already uploaded
|
||||
|
@ -337,6 +547,7 @@ export default ({ onClose, replyToStatus, editStatus }) => {
|
|||
status,
|
||||
spoilerText,
|
||||
sensitive,
|
||||
poll,
|
||||
mediaIds: mediaAttachments.map((attachment) => attachment.id),
|
||||
};
|
||||
if (!editStatus) {
|
||||
|
@ -385,6 +596,7 @@ export default ({ onClose, replyToStatus, editStatus }) => {
|
|||
<input
|
||||
name="sensitive"
|
||||
type="checkbox"
|
||||
checked={sensitive}
|
||||
disabled={uiState === 'loading' || !!editStatus}
|
||||
onChange={(e) => {
|
||||
const sensitive = e.target.checked;
|
||||
|
@ -482,8 +694,25 @@ export default ({ onClose, replyToStatus, editStatus }) => {
|
|||
})}
|
||||
</div>
|
||||
)}
|
||||
{!!poll && (
|
||||
<Poll
|
||||
maxOptions={maxOptions}
|
||||
maxExpiration={maxExpiration}
|
||||
minExpiration={minExpiration}
|
||||
maxCharactersPerOption={maxCharactersPerOption}
|
||||
poll={poll}
|
||||
disabled={uiState === 'loading'}
|
||||
onInput={(poll) => {
|
||||
if (poll) {
|
||||
const newPoll = { ...poll };
|
||||
setPoll(newPoll);
|
||||
} else {
|
||||
setPoll(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<label class="toolbar-button">
|
||||
<input
|
||||
type="file"
|
||||
|
@ -491,7 +720,8 @@ export default ({ onClose, replyToStatus, editStatus }) => {
|
|||
multiple={mediaAttachments.length < maxMediaAttachments - 1}
|
||||
disabled={
|
||||
uiState === 'loading' ||
|
||||
mediaAttachments.length >= maxMediaAttachments
|
||||
mediaAttachments.length >= maxMediaAttachments ||
|
||||
!!poll
|
||||
}
|
||||
onChange={(e) => {
|
||||
const files = e.target.files;
|
||||
|
@ -523,23 +753,33 @@ export default ({ onClose, replyToStatus, editStatus }) => {
|
|||
}}
|
||||
/>
|
||||
<Icon icon="attachment" />
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
{uiState === 'loading' && <Loader abrupt />}{' '}
|
||||
</label>{' '}
|
||||
<button
|
||||
type="submit"
|
||||
class="large"
|
||||
disabled={uiState === 'loading'}
|
||||
type="button"
|
||||
class="toolbar-button"
|
||||
disabled={
|
||||
uiState === 'loading' || !!poll || !!mediaAttachments.length
|
||||
}
|
||||
onClick={() => {
|
||||
setPoll({
|
||||
options: ['', ''],
|
||||
expiresIn: 24 * 60 * 60, // 1 day
|
||||
multiple: false,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon icon="poll" alt="Add poll" />
|
||||
</button>{' '}
|
||||
<div class="spacer" />
|
||||
{uiState === 'loading' && <Loader abrupt />}{' '}
|
||||
<button type="submit" class="large" disabled={uiState === 'loading'}>
|
||||
{replyToStatus ? 'Reply' : editStatus ? 'Update' : 'Post'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function MediaAttachment({
|
||||
attachment,
|
||||
|
@ -602,3 +842,112 @@ function MediaAttachment({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Poll({
|
||||
poll,
|
||||
disabled,
|
||||
onInput = () => {},
|
||||
maxOptions,
|
||||
maxExpiration,
|
||||
minExpiration,
|
||||
maxCharactersPerOption,
|
||||
}) {
|
||||
const { options, expiresIn, multiple } = poll;
|
||||
|
||||
return (
|
||||
<div class={`poll ${multiple ? 'multiple' : ''}`}>
|
||||
<div class="poll-choices">
|
||||
{options.map((option, i) => (
|
||||
<div class="poll-choice" key={i}>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
value={option}
|
||||
disabled={disabled}
|
||||
maxlength={maxCharactersPerOption}
|
||||
placeholder={`Choice ${i + 1}`}
|
||||
onInput={(e) => {
|
||||
const { value } = e.target;
|
||||
options[i] = value;
|
||||
onInput(poll);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="plain2 poll-button"
|
||||
disabled={disabled || options.length <= 1}
|
||||
onClick={() => {
|
||||
options.splice(i, 1);
|
||||
onInput(poll);
|
||||
}}
|
||||
>
|
||||
<Icon icon="x" size="s" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div class="poll-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
class="plain2 poll-button"
|
||||
disabled={disabled || options.length >= maxOptions}
|
||||
onClick={() => {
|
||||
options.push('');
|
||||
onInput(poll);
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>{' '}
|
||||
<label class="multiple-choices">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={multiple}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const { checked } = e.target;
|
||||
poll.multiple = checked;
|
||||
onInput(poll);
|
||||
}}
|
||||
/>{' '}
|
||||
Multiple choices
|
||||
</label>
|
||||
<label class="expires-in">
|
||||
Duration{' '}
|
||||
<select
|
||||
value={expiresIn}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const { value } = e.target;
|
||||
poll.expiresIn = value;
|
||||
onInput(poll);
|
||||
}}
|
||||
>
|
||||
{Object.entries(expiryOptions)
|
||||
.filter(([label, value]) => {
|
||||
return value >= minExpiration && value <= maxExpiration;
|
||||
})
|
||||
.map(([label, value]) => (
|
||||
<option value={value} key={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="poll-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
class="plain remove-poll-button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onInput(null);
|
||||
}}
|
||||
>
|
||||
Remove poll
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Compose;
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import 'iconify-icon';
|
||||
|
||||
const SIZES = {
|
||||
s: 12,
|
||||
m: 16,
|
||||
|
@ -35,11 +37,21 @@ const ICONS = {
|
|||
upload: 'mingcute:upload-3-line',
|
||||
gear: 'mingcute:settings-3-line',
|
||||
more: 'mingcute:more-1-line',
|
||||
external: 'mingcute:external-link-line',
|
||||
popout: 'mingcute:external-link-line',
|
||||
popin: ['mingcute:external-link-line', '180deg'],
|
||||
plus: 'mingcute:add-circle-line',
|
||||
};
|
||||
|
||||
export default ({ icon, size = 'm', alt, title, class: className = '' }) => {
|
||||
if (!icon) return null;
|
||||
|
||||
const iconSize = SIZES[size];
|
||||
const iconName = ICONS[icon];
|
||||
let iconName = ICONS[icon];
|
||||
let rotate;
|
||||
if (Array.isArray(iconName)) {
|
||||
[iconName, rotate] = iconName;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
class={`icon ${className}`}
|
||||
|
@ -52,7 +64,12 @@ export default ({ icon, size = 'm', alt, title, class: className = '' }) => {
|
|||
lineHeight: 0,
|
||||
}}
|
||||
>
|
||||
<iconify-icon width={iconSize} height={iconSize} icon={iconName}>
|
||||
<iconify-icon
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
icon={iconName}
|
||||
rotate={rotate}
|
||||
>
|
||||
{alt}
|
||||
</iconify-icon>
|
||||
</div>
|
||||
|
|
|
@ -42,6 +42,12 @@
|
|||
opacity: 0.75;
|
||||
font-size: smaller;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
.status-pre-meta .name-text {
|
||||
display: inline;
|
||||
}
|
||||
.status-pre-meta .icon {
|
||||
color: var(--reblog-color);
|
||||
|
@ -93,11 +99,13 @@
|
|||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.status:not(.small) .container {
|
||||
padding-left: 16px;
|
||||
.status .container {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.status:not(.small) .container {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.status > .container > .meta {
|
||||
display: flex;
|
||||
|
@ -175,9 +183,6 @@
|
|||
.status .content p:last-child {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
.status .content p code {
|
||||
color: var(--green-color);
|
||||
}
|
||||
.status .content .invisible {
|
||||
display: none;
|
||||
}
|
||||
|
@ -186,10 +191,12 @@
|
|||
}
|
||||
.status.large .content {
|
||||
font-size: 150%;
|
||||
font-size: calc(100% + 50% / var(--content-text-weight));
|
||||
}
|
||||
.status.large .poll,
|
||||
.status.large .actions {
|
||||
font-size: 125%;
|
||||
font-size: calc(100% + 25% / var(--content-text-weight));
|
||||
}
|
||||
|
||||
/* MEDIA */
|
||||
|
@ -207,6 +214,7 @@
|
|||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
max-height: 160px;
|
||||
min-height: 80px;
|
||||
border: 1px solid var(--outline-color);
|
||||
}
|
||||
.status .media:hover {
|
||||
|
@ -374,6 +382,7 @@ a.card:hover {
|
|||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
background-color: var(--bg-blur-color);
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--link-faded-color),
|
||||
|
@ -391,11 +400,19 @@ a.card:hover {
|
|||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.poll-option-votes {
|
||||
flex-shrink: 0;
|
||||
font-size: 90%;
|
||||
}
|
||||
.poll-option-leading .poll-option-votes {
|
||||
font-weight: bold;
|
||||
}
|
||||
.poll-vote-button {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.poll-meta {
|
||||
margin: 8px 0;
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
/* EXTRA META */
|
||||
|
|
|
@ -10,6 +10,7 @@ import Loader from '../components/loader';
|
|||
import Modal from '../components/modal';
|
||||
import NameText from '../components/name-text';
|
||||
import enhanceContent from '../utils/enhance-content';
|
||||
import htmlContentLength from '../utils/html-content-length';
|
||||
import shortenNumber from '../utils/shorten-number';
|
||||
import states from '../utils/states';
|
||||
import store from '../utils/store';
|
||||
|
@ -264,10 +265,14 @@ function Card({ card }) {
|
|||
}
|
||||
}
|
||||
|
||||
function Poll({ poll }) {
|
||||
function Poll({ poll, readOnly }) {
|
||||
const [pollSnapshot, setPollSnapshot] = useState(poll);
|
||||
const [uiState, setUIState] = useState('default');
|
||||
|
||||
useEffect(() => {
|
||||
setPollSnapshot(poll);
|
||||
}, [poll]);
|
||||
|
||||
const {
|
||||
expired,
|
||||
expiresAt,
|
||||
|
@ -280,17 +285,24 @@ function Poll({ poll }) {
|
|||
votesCount,
|
||||
} = pollSnapshot;
|
||||
|
||||
const expiresAtDate = new Date(expiresAt);
|
||||
const expiresAtDate = !!expiresAt && new Date(expiresAt);
|
||||
|
||||
return (
|
||||
<div class="poll">
|
||||
{voted || expired ? (
|
||||
options.map((option, i) => {
|
||||
const { title, votesCount: optionVotesCount } = option;
|
||||
const percentage = Math.round((optionVotesCount / votesCount) * 100);
|
||||
const pollVotesCount = votersCount || votesCount;
|
||||
const percentage =
|
||||
Math.round((optionVotesCount / pollVotesCount) * 100) || 0;
|
||||
// check if current poll choice is the leading one
|
||||
const isLeading =
|
||||
optionVotesCount > 0 &&
|
||||
optionVotesCount === Math.max(...options.map((o) => o.votesCount));
|
||||
return (
|
||||
<div
|
||||
class="poll-option"
|
||||
key={`${i}-${title}-${optionVotesCount}`}
|
||||
class={`poll-option ${isLeading ? 'poll-option-leading' : ''}`}
|
||||
style={{
|
||||
'--percentage': `${percentage}%`,
|
||||
}}
|
||||
|
@ -300,7 +312,7 @@ function Poll({ poll }) {
|
|||
{voted && ownVotes.includes(i) && (
|
||||
<>
|
||||
{' '}
|
||||
<Icon icon="check" size="s" />
|
||||
<Icon icon="check-circle" size="s" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
@ -337,7 +349,7 @@ function Poll({ poll }) {
|
|||
setUIState('default');
|
||||
}}
|
||||
style={{
|
||||
pointerEvents: uiState === 'loading' ? 'none' : 'auto',
|
||||
pointerEvents: uiState === 'loading' || readOnly ? 'none' : 'auto',
|
||||
opacity: uiState === 'loading' ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
|
@ -351,12 +363,14 @@ function Poll({ poll }) {
|
|||
name="poll"
|
||||
value={i}
|
||||
disabled={uiState === 'loading'}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
<span class="poll-option-title">{title}</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!readOnly && (
|
||||
<button
|
||||
class="poll-vote-button"
|
||||
type="submit"
|
||||
|
@ -364,8 +378,10 @@ function Poll({ poll }) {
|
|||
>
|
||||
Vote
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<p class="poll-meta">
|
||||
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
|
||||
{votersCount === 1 ? 'voter' : 'voters'}
|
||||
|
@ -380,8 +396,11 @@ function Poll({ poll }) {
|
|||
</>
|
||||
)}{' '}
|
||||
• {expired ? 'Ended' : 'Ending'}{' '}
|
||||
{!!expiresAtDate && (
|
||||
<relative-time datetime={expiresAtDate.toISOString()} />
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -443,7 +462,7 @@ function EditedAtModal({ statusID, onClose = () => {} }) {
|
|||
}).format(createdAtDate)}
|
||||
</time>
|
||||
</h3>
|
||||
<Status status={status} size="s" withinContext editStatus />
|
||||
<Status status={status} size="s" withinContext readOnly />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
@ -464,7 +483,7 @@ function Status({
|
|||
withinContext,
|
||||
size = 'm',
|
||||
skeleton,
|
||||
editStatus,
|
||||
readOnly,
|
||||
}) {
|
||||
if (skeleton) {
|
||||
return (
|
||||
|
@ -639,7 +658,7 @@ function Status({
|
|||
</>
|
||||
)}
|
||||
</span>{' '}
|
||||
{size !== 'l' && !editStatus && (
|
||||
{size !== 'l' && uri ? (
|
||||
<a href={uri} target="_blank" class="time">
|
||||
<Icon
|
||||
icon={visibilityIconsMap[visibility]}
|
||||
|
@ -655,12 +674,36 @@ function Status({
|
|||
{createdAtDate.toLocaleString()}
|
||||
</relative-time>
|
||||
</a>
|
||||
) : (
|
||||
<span class="time">
|
||||
<Icon
|
||||
icon={visibilityIconsMap[visibility]}
|
||||
alt={visibility}
|
||||
size="s"
|
||||
/>{' '}
|
||||
<relative-time
|
||||
datetime={createdAtDate.toISOString()}
|
||||
format="micro"
|
||||
threshold="P1D"
|
||||
prefix=""
|
||||
>
|
||||
{createdAtDate.toLocaleString()}
|
||||
</relative-time>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
class={`content-container ${
|
||||
sensitive || spoilerText ? 'has-spoiler' : ''
|
||||
} ${showSpoiler ? 'show-spoiler' : ''}`}
|
||||
style={
|
||||
size === 'l' && {
|
||||
'--content-text-weight':
|
||||
Math.round(
|
||||
(spoilerText.length + htmlContentLength(content)) / 140,
|
||||
) || 1,
|
||||
}
|
||||
}
|
||||
>
|
||||
{!!spoilerText && sensitive && (
|
||||
<>
|
||||
|
@ -694,9 +737,14 @@ function Status({
|
|||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const username = target.querySelector('span');
|
||||
const username = (
|
||||
target.querySelector('span') || target
|
||||
).innerText
|
||||
.trim()
|
||||
.replace(/^@/, '');
|
||||
const mention = mentions.find(
|
||||
(mention) => mention.username === username?.innerText.trim(),
|
||||
(mention) =>
|
||||
mention.username === username || mention.acct === username,
|
||||
);
|
||||
if (mention) {
|
||||
states.showAccount = mention.acct;
|
||||
|
@ -720,7 +768,7 @@ function Status({
|
|||
}),
|
||||
}}
|
||||
/>
|
||||
{!!poll && <Poll poll={poll} />}
|
||||
{!!poll && <Poll poll={poll} readOnly={readOnly} />}
|
||||
{!spoilerText && sensitive && !!mediaAttachments.length && (
|
||||
<button
|
||||
class="plain spoiler"
|
||||
|
|
87
src/compose.jsx
Normal file
87
src/compose.jsx
Normal file
|
@ -0,0 +1,87 @@
|
|||
import './index.css';
|
||||
|
||||
import './app.css';
|
||||
|
||||
import '@github/time-elements';
|
||||
import { login } from 'masto';
|
||||
import { render } from 'preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
import Compose from './components/compose';
|
||||
import store from './utils/store';
|
||||
|
||||
if (window.opener) {
|
||||
console = window.opener.console;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
if (window.masto) return;
|
||||
console.warn('window.masto not found. Trying to log in...');
|
||||
try {
|
||||
const accounts = store.local.getJSON('accounts') || [];
|
||||
const currentAccount = store.session.get('currentAccount');
|
||||
const account =
|
||||
accounts.find((a) => a.info.id === currentAccount) || accounts[0];
|
||||
const instanceURL = account.instanceURL;
|
||||
const accessToken = account.accessToken;
|
||||
window.masto = await login({
|
||||
url: `https://${instanceURL}`,
|
||||
accessToken,
|
||||
});
|
||||
console.info('Logged in successfully.');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Failed to log in. Please try again.');
|
||||
}
|
||||
})();
|
||||
|
||||
function App() {
|
||||
const [uiState, setUIState] = useState('default');
|
||||
|
||||
const { editStatus, replyToStatus, draftStatus } = window.__COMPOSE__ || {};
|
||||
|
||||
useEffect(() => {
|
||||
if (uiState === 'closed') {
|
||||
window.close();
|
||||
}
|
||||
}, [uiState]);
|
||||
|
||||
if (uiState === 'closed') {
|
||||
return (
|
||||
<div class="box">
|
||||
<p>You may close this page now.</p>
|
||||
<p>
|
||||
<button
|
||||
onClick={() => {
|
||||
window.close();
|
||||
}}
|
||||
>
|
||||
Close window
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Compose
|
||||
editStatus={editStatus}
|
||||
replyToStatus={replyToStatus}
|
||||
draftStatus={draftStatus}
|
||||
standalone
|
||||
hasOpener={window.opener}
|
||||
onClose={(results) => {
|
||||
const { newStatus, fn = () => {} } = results || {};
|
||||
try {
|
||||
if (newStatus) {
|
||||
window.opener.__STATES__.reloadStatusPage++;
|
||||
}
|
||||
fn();
|
||||
setUIState('closed');
|
||||
} catch (e) {}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<App />, document.getElementById('app-standalone'));
|
|
@ -114,7 +114,6 @@ button,
|
|||
border: 0;
|
||||
background-color: var(--button-bg-color);
|
||||
color: var(--button-text-color);
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
text-decoration: none;
|
||||
|
@ -122,14 +121,14 @@ button,
|
|||
button > * {
|
||||
vertical-align: middle;
|
||||
}
|
||||
:is(button, .button):not([disabled]):hover {
|
||||
:is(button, .button):not(:disabled, .disabled):hover {
|
||||
cursor: pointer;
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
:is(button, .button):active {
|
||||
:is(button, .button):not(:disabled, .disabled):active {
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
:is(button, .button)[disabled] {
|
||||
cursor: auto;
|
||||
:is(button:disabled, .button.disabled) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
|
@ -215,6 +214,10 @@ code {
|
|||
display: inline-block;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
/* KEYFRAMES */
|
||||
|
||||
@keyframes fade-in {
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import './index.css';
|
||||
|
||||
import '@github/time-elements';
|
||||
import 'iconify-icon';
|
||||
import { render } from 'preact';
|
||||
|
||||
import { App } from './app';
|
||||
|
|
|
@ -49,7 +49,6 @@ export default ({ hidden }) => {
|
|||
states.home.push(...homeValues);
|
||||
}
|
||||
states.homeLastFetchTime = Date.now();
|
||||
console.log(allStatuses);
|
||||
return allStatuses;
|
||||
}
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ import { Link } from 'preact-router/match';
|
|||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import Avatar from '../components/avatar';
|
||||
import Icon from '../components/icon';
|
||||
import Loader from '../components/loader';
|
||||
import NameText from '../components/name-text';
|
||||
|
@ -43,13 +44,13 @@ const contentText = {
|
|||
const LIMIT = 20;
|
||||
|
||||
function Notification({ notification }) {
|
||||
const { id, type, status, account } = notification;
|
||||
const { id, type, status, account, _accounts } = notification;
|
||||
|
||||
// status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update
|
||||
const actualStatusID = status?.reblog?.id || status?.id;
|
||||
|
||||
const currentAccount = store.session.get('currentAccount');
|
||||
const isSelf = currentAccount?.id === account.id;
|
||||
const isSelf = currentAccount === account?.id;
|
||||
const isVoted = status?.poll?.voted;
|
||||
|
||||
const text =
|
||||
|
@ -83,12 +84,51 @@ function Notification({ notification }) {
|
|||
<div class="notification-content">
|
||||
<p>
|
||||
{!/poll|update/i.test(type) && (
|
||||
<>
|
||||
{_accounts?.length > 1 ? (
|
||||
<>
|
||||
<b>{_accounts.length} people</b>{' '}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<NameText account={account} showAvatar />{' '}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{text}
|
||||
</p>
|
||||
{_accounts?.length > 1 && (
|
||||
<p>
|
||||
{_accounts.map((account, i) => (
|
||||
<>
|
||||
<a
|
||||
href={account.url}
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
states.showAccount = account;
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
url={account.avatarStatic}
|
||||
size={
|
||||
_accounts.length < 10
|
||||
? 'xl'
|
||||
: _accounts.length < 100
|
||||
? 'l'
|
||||
: _accounts.length < 1000
|
||||
? 'm'
|
||||
: 's' // My god, this person is popular!
|
||||
}
|
||||
key={account.id}
|
||||
alt={`${account.displayName} @${account.acct}`}
|
||||
/>
|
||||
</a>{' '}
|
||||
</>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
{status && (
|
||||
<Link class="status-link" href={`#/s/${actualStatusID}`}>
|
||||
<Status status={status} size="s" />
|
||||
|
@ -103,9 +143,34 @@ function NotificationsList({ notifications, emptyCopy }) {
|
|||
if (!notifications.length && emptyCopy) {
|
||||
return <p class="timeline-empty">{emptyCopy}</p>;
|
||||
}
|
||||
|
||||
// Create new flat list of notifications
|
||||
// Combine sibling notifications based on type and status id, ignore the id
|
||||
// Concat all notification.account into an array of _accounts
|
||||
const cleanNotifications = [notifications[0]];
|
||||
for (let i = 1, j = 0; i < notifications.length; i++) {
|
||||
const notification = notifications[i];
|
||||
const cleanNotification = cleanNotifications[j];
|
||||
const { status, account, type } = notification;
|
||||
if (
|
||||
account &&
|
||||
cleanNotification?.account &&
|
||||
cleanNotification?.status?.id === status?.id &&
|
||||
cleanNotification?.type === type
|
||||
) {
|
||||
cleanNotification._accounts.push(account);
|
||||
} else {
|
||||
cleanNotifications[++j] = {
|
||||
...notification,
|
||||
_accounts: [account],
|
||||
};
|
||||
}
|
||||
}
|
||||
// console.log({ notifications, cleanNotifications });
|
||||
|
||||
return (
|
||||
<ul class="timeline flat">
|
||||
{notifications.map((notification) => {
|
||||
{cleanNotifications.map((notification, i) => {
|
||||
const { id, type } = notification;
|
||||
return (
|
||||
<li key={id} class={`notification ${type}`}>
|
||||
|
@ -151,7 +216,6 @@ export default () => {
|
|||
states.notifications.push(...notificationsValues);
|
||||
}
|
||||
states.notificationsLastFetchTime = Date.now();
|
||||
console.log(allNotifications);
|
||||
return allNotifications;
|
||||
}
|
||||
|
||||
|
@ -201,8 +265,7 @@ export default () => {
|
|||
},
|
||||
{ today: [], yesterday: [], older: [] },
|
||||
);
|
||||
|
||||
console.log(groupedNotifications);
|
||||
// console.log(groupedNotifications);
|
||||
|
||||
return (
|
||||
<div class="deck-container" ref={scrollableRef}>
|
||||
|
|
5
src/utils/html-content-length.js
Normal file
5
src/utils/html-content-length.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
const div = document.createElement('div');
|
||||
export default (html) => {
|
||||
div.innerHTML = html;
|
||||
return div.innerText.length;
|
||||
};
|
23
src/utils/open-compose.js
Normal file
23
src/utils/open-compose.js
Normal file
|
@ -0,0 +1,23 @@
|
|||
export default (opts) => {
|
||||
const url = new URL('/compose/', window.location);
|
||||
const { width: screenWidth, height: screenHeight } = window.screen;
|
||||
const left = Math.max(0, (screenWidth - 600) / 2);
|
||||
const top = Math.max(0, (screenHeight - 450) / 2);
|
||||
const width = Math.min(screenWidth, 600);
|
||||
const height = Math.min(screenHeight, 450);
|
||||
const newWin = window.open(
|
||||
url,
|
||||
'compose' + Math.random(),
|
||||
`width=${width},height=${height},left=${left},top=${top}`,
|
||||
);
|
||||
|
||||
if (newWin) {
|
||||
if (masto) {
|
||||
newWin.masto = masto;
|
||||
}
|
||||
|
||||
newWin.__COMPOSE__ = opts;
|
||||
}
|
||||
|
||||
return newWin;
|
||||
};
|
|
@ -1,7 +1,17 @@
|
|||
import preact from '@preact/preset-vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import { resolve } from 'path';
|
||||
import { defineConfig, splitVendorChunkPlugin } from 'vite';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [preact()],
|
||||
plugins: [preact(), splitVendorChunkPlugin()],
|
||||
build: {
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(__dirname, 'index.html'),
|
||||
compose: resolve(__dirname, 'compose/index.html'),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue