Merge pull request #1 from cheeaun/main

Update from main
This commit is contained in:
Chee Aun 2022-12-15 12:02:26 +08:00 committed by GitHub
commit a45250ac96
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 1533 additions and 755 deletions

16
.github/workflows/main2prod.yml vendored Normal file
View 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'

View file

@ -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
View 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.

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -4,7 +4,7 @@ body {
padding: 0;
background-color: var(--bg-color);
color: var(--text-color);
overflow: hidden;
/* overflow: hidden; */
}
#app {

View file

@ -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++;
}
}}

View file

@ -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" />

View file

@ -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);
}

View file

@ -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}
/>
)}
<button
type="button"
class="light close-button"
onClick={() => {
if (canClose()) {
onClose();
}
}}
>
<Icon icon="x" />
</button>
{!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 (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,64 +694,92 @@ 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"
accept={supportedMimeTypes.join(',')}
multiple={mediaAttachments.length < maxMediaAttachments - 1}
disabled={
uiState === 'loading' ||
mediaAttachments.length >= maxMediaAttachments
<label class="toolbar-button">
<input
type="file"
accept={supportedMimeTypes.join(',')}
multiple={mediaAttachments.length < maxMediaAttachments - 1}
disabled={
uiState === 'loading' ||
mediaAttachments.length >= maxMediaAttachments ||
!!poll
}
onChange={(e) => {
const files = e.target.files;
if (!files) return;
const mediaFiles = Array.from(files).map((file) => ({
file,
type: file.type,
size: file.size,
url: URL.createObjectURL(file),
id: null, // indicate uploaded state
description: null,
}));
console.log('MEDIA ATTACHMENTS', files, mediaFiles);
// Validate max media attachments
if (
mediaAttachments.length + mediaFiles.length >
maxMediaAttachments
) {
alert(
`You can only attach up to ${maxMediaAttachments} files.`,
);
} else {
setMediaAttachments((attachments) => {
return attachments.concat(mediaFiles);
});
}
onChange={(e) => {
const files = e.target.files;
if (!files) return;
const mediaFiles = Array.from(files).map((file) => ({
file,
type: file.type,
size: file.size,
url: URL.createObjectURL(file),
id: null, // indicate uploaded state
description: null,
}));
console.log('MEDIA ATTACHMENTS', files, mediaFiles);
// Validate max media attachments
if (
mediaAttachments.length + mediaFiles.length >
maxMediaAttachments
) {
alert(
`You can only attach up to ${maxMediaAttachments} files.`,
);
} else {
setMediaAttachments((attachments) => {
return attachments.concat(mediaFiles);
});
}
}}
/>
<Icon icon="attachment" />
</label>
</div>
<div>
{uiState === 'loading' && <Loader abrupt />}{' '}
<button
type="submit"
class="large"
disabled={uiState === 'loading'}
>
{replyToStatus ? 'Reply' : editStatus ? 'Update' : 'Post'}
</button>
</div>
}}
/>
<Icon icon="attachment" />
</label>{' '}
<button
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>
</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;

View file

@ -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>

View file

@ -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 */

View file

@ -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,37 +363,44 @@ function Poll({ poll }) {
name="poll"
value={i}
disabled={uiState === 'loading'}
readOnly={readOnly}
/>
<span class="poll-option-title">{title}</span>
</label>
</div>
);
})}
<button
class="poll-vote-button"
type="submit"
disabled={uiState === 'loading'}
>
Vote
</button>
{!readOnly && (
<button
class="poll-vote-button"
type="submit"
disabled={uiState === 'loading'}
>
Vote
</button>
)}
</form>
)}
<p class="poll-meta">
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
{votersCount === 1 ? 'voter' : 'voters'}
{votersCount !== votesCount && (
<>
{' '}
&bull; <span title={votesCount}>
{shortenNumber(votesCount)}
</span>{' '}
vote
{votesCount === 1 ? '' : 's'}
</>
)}{' '}
&bull; {expired ? 'Ended' : 'Ending'}{' '}
<relative-time datetime={expiresAtDate.toISOString()} />
</p>
{!readOnly && (
<p class="poll-meta">
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
{votersCount === 1 ? 'voter' : 'voters'}
{votersCount !== votesCount && (
<>
{' '}
&bull; <span title={votesCount}>
{shortenNumber(votesCount)}
</span>{' '}
vote
{votesCount === 1 ? '' : 's'}
</>
)}{' '}
&bull; {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
View 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'));

View file

@ -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 {

View file

@ -1,7 +1,6 @@
import './index.css';
import '@github/time-elements';
import 'iconify-icon';
import { render } from 'preact';
import { App } from './app';

View file

@ -49,7 +49,6 @@ export default ({ hidden }) => {
states.home.push(...homeValues);
}
states.homeLastFetchTime = Date.now();
console.log(allStatuses);
return allStatuses;
}

View file

@ -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 =
@ -84,11 +85,50 @@ function Notification({ notification }) {
<p>
{!/poll|update/i.test(type) && (
<>
<NameText account={account} showAvatar />{' '}
{_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}>

View 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
View 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;
};

View file

@ -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'),
},
},
},
});