Compare commits
10 commits
3c32b48e29
...
61fc11a65e
Author | SHA1 | Date | |
---|---|---|---|
![]() |
61fc11a65e | ||
![]() |
685572bcd8 | ||
![]() |
774aa7a21c | ||
![]() |
276c6e7bea | ||
![]() |
f61054a3d5 | ||
![]() |
b1dc43a9c9 | ||
![]() |
040462f5b5 | ||
![]() |
f5f3395a53 | ||
![]() |
3fb152ac7c | ||
![]() |
97e3b04f1f |
15 changed files with 199 additions and 77 deletions
33
.drone.yml
Normal file
33
.drone.yml
Normal file
|
@ -0,0 +1,33 @@
|
|||
kind: pipeline
|
||||
name: default
|
||||
|
||||
steps:
|
||||
- name: docker
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: docker.data.coop/pinafore
|
||||
registry: docker.data.coop
|
||||
username:
|
||||
from_secret: DOCKER_USERNAME
|
||||
password:
|
||||
from_secret: DOCKER_PASSWORD
|
||||
tags:
|
||||
- "${DRONE_BUILD_NUMBER}"
|
||||
- "${DRONE_TAG}"
|
||||
- "latest"
|
||||
when:
|
||||
ref:
|
||||
- refs/tags/*
|
||||
event:
|
||||
exclude:
|
||||
- pull_request
|
||||
|
||||
- name: notify
|
||||
image: plugins/matrix
|
||||
settings:
|
||||
homeserver: https://data.coop
|
||||
roomid: plKSghHbepWeUEtbHE:data.coop
|
||||
username:
|
||||
from_secret: matrix_username
|
||||
password:
|
||||
from_secret: matrix_password
|
|
@ -1,5 +1,6 @@
|
|||
export default [
|
||||
{ id: 'pinafore-logo', src: 'src/static/sailboat.svg', inline: true },
|
||||
{ id: 'fa-arrow-left', src: 'src/thirdparty/font-awesome-svg-png/white/svg/arrow-left.svg' },
|
||||
{ id: 'fa-bell', src: 'src/thirdparty/font-awesome-svg-png/white/svg/bell.svg', inline: true },
|
||||
{ id: 'fa-bell-o', src: 'src/thirdparty/font-awesome-svg-png/white/svg/bell-o.svg' },
|
||||
{ id: 'fa-users', src: 'src/thirdparty/font-awesome-svg-png/white/svg/users.svg', inline: true },
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "pinafore",
|
||||
"description": "Alternative web client for Mastodon",
|
||||
"version": "2.4.0",
|
||||
"version": "2.5.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || ^16.0.0 || ^18.0.0"
|
||||
|
|
|
@ -497,6 +497,8 @@ export default {
|
|||
}: {description}`,
|
||||
accountFollowedYou: '{name} followed you, {account}',
|
||||
accountSignedUp: '{name} signed up, {account}',
|
||||
accountRequestedFollow: '{name} requested to follow you, {account}',
|
||||
accountReported: '{name} filed a report, {account}',
|
||||
reblogCountsHidden: 'Boost counts hidden',
|
||||
favoriteCountsHidden: 'Favorite counts hidden',
|
||||
rebloggedTimes: `Boosted {count, plural,
|
||||
|
@ -512,6 +514,8 @@ export default {
|
|||
favoritedYou: 'favorited your toot',
|
||||
followedYou: 'followed you',
|
||||
edited: 'edited their toot',
|
||||
requestedFollow: 'requested to follow you',
|
||||
reported: 'filed a report',
|
||||
signedUp: 'signed up',
|
||||
posted: 'posted',
|
||||
pollYouCreatedEnded: 'A poll you created has ended',
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { database } from '../_database/database.js'
|
||||
import { decode as decodeBlurhash, init as initBlurhash } from '../_utils/blurhash.js'
|
||||
import { mark, stop } from '../_utils/marks.js'
|
||||
import { get } from '../_utils/lodash-lite.js'
|
||||
import { statusHtmlToPlainText } from '../_utils/statusHtmlToPlainText.js'
|
||||
import { scheduleIdleTask } from '../_utils/scheduleIdleTask.js'
|
||||
import { prepareToRehydrate, rehydrateStatusOrNotification } from './rehydrateStatusOrNotification.js'
|
||||
|
||||
async function getNotification (instanceName, timelineType, timelineValue, itemId) {
|
||||
return {
|
||||
|
@ -21,62 +18,10 @@ async function getStatus (instanceName, timelineType, timelineValue, itemId) {
|
|||
}
|
||||
}
|
||||
|
||||
function tryInitBlurhash () {
|
||||
try {
|
||||
initBlurhash()
|
||||
} catch (err) {
|
||||
console.error('could not start blurhash worker', err)
|
||||
}
|
||||
}
|
||||
|
||||
function getActualStatus (statusOrNotification) {
|
||||
return get(statusOrNotification, ['status']) ||
|
||||
get(statusOrNotification, ['notification', 'status'])
|
||||
}
|
||||
|
||||
async function decodeAllBlurhashes (statusOrNotification) {
|
||||
const status = getActualStatus(statusOrNotification)
|
||||
if (!status) {
|
||||
return
|
||||
}
|
||||
const mediaWithBlurhashes = get(status, ['media_attachments'], [])
|
||||
.concat(get(status, ['reblog', 'media_attachments'], []))
|
||||
.filter(_ => _.blurhash)
|
||||
if (mediaWithBlurhashes.length) {
|
||||
mark(`decodeBlurhash-${status.id}`)
|
||||
await Promise.all(mediaWithBlurhashes.map(async media => {
|
||||
try {
|
||||
media.decodedBlurhash = await decodeBlurhash(media.blurhash)
|
||||
} catch (err) {
|
||||
console.warn('Could not decode blurhash, ignoring', err)
|
||||
}
|
||||
}))
|
||||
stop(`decodeBlurhash-${status.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function calculatePlainTextContent (statusOrNotification) {
|
||||
const status = getActualStatus(statusOrNotification)
|
||||
if (!status) {
|
||||
return
|
||||
}
|
||||
const originalStatus = status.reblog ? status.reblog : status
|
||||
const content = originalStatus.content || ''
|
||||
const mentions = originalStatus.mentions || []
|
||||
// Calculating the plaintext from the HTML is a non-trivial operation, so we might
|
||||
// as well do it in advance, while blurhash is being decoded on the worker thread.
|
||||
await new Promise(resolve => {
|
||||
scheduleIdleTask(() => {
|
||||
originalStatus.plainTextContent = statusHtmlToPlainText(content, mentions)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function createMakeProps (instanceName, timelineType, timelineValue) {
|
||||
let promiseChain = Promise.resolve()
|
||||
|
||||
tryInitBlurhash() // start the blurhash worker a bit early to save time
|
||||
prepareToRehydrate() // start blurhash early to save time
|
||||
|
||||
async function fetchFromIndexedDB (itemId) {
|
||||
mark(`fetchFromIndexedDB-${itemId}`)
|
||||
|
@ -92,10 +37,7 @@ export function createMakeProps (instanceName, timelineType, timelineValue) {
|
|||
|
||||
async function getStatusOrNotification (itemId) {
|
||||
const statusOrNotification = await fetchFromIndexedDB(itemId)
|
||||
await Promise.all([
|
||||
decodeAllBlurhashes(statusOrNotification),
|
||||
calculatePlainTextContent(statusOrNotification)
|
||||
])
|
||||
await rehydrateStatusOrNotification(statusOrNotification)
|
||||
return statusOrNotification
|
||||
}
|
||||
|
||||
|
|
|
@ -4,18 +4,28 @@ import { database } from '../_database/database.js'
|
|||
import {
|
||||
getPinnedStatuses
|
||||
} from '../_api/pinnedStatuses.js'
|
||||
import { prepareToRehydrate, rehydrateStatusOrNotification } from './rehydrateStatusOrNotification.js'
|
||||
|
||||
// Pinned statuses aren't a "normal" timeline, so their blurhashes/plaintext need to be calculated specially
|
||||
async function rehydratePinnedStatuses (statuses) {
|
||||
await Promise.all(statuses.map(status => rehydrateStatusOrNotification({ status })))
|
||||
return statuses
|
||||
}
|
||||
|
||||
export async function updatePinnedStatusesForAccount (accountId) {
|
||||
const { currentInstance, accessToken } = store.get()
|
||||
|
||||
await cacheFirstUpdateAfter(
|
||||
() => getPinnedStatuses(currentInstance, accessToken, accountId),
|
||||
async () => {
|
||||
return rehydratePinnedStatuses(await getPinnedStatuses(currentInstance, accessToken, accountId))
|
||||
},
|
||||
async () => {
|
||||
prepareToRehydrate() // start blurhash early to save time
|
||||
const pinnedStatuses = await database.getPinnedStatuses(currentInstance, accountId)
|
||||
if (!pinnedStatuses || !pinnedStatuses.every(Boolean)) {
|
||||
throw new Error('missing pinned statuses in idb')
|
||||
}
|
||||
return pinnedStatuses
|
||||
return rehydratePinnedStatuses(pinnedStatuses)
|
||||
},
|
||||
statuses => database.insertPinnedStatuses(currentInstance, accountId, statuses),
|
||||
statuses => {
|
||||
|
|
67
src/routes/_actions/rehydrateStatusOrNotification.js
Normal file
67
src/routes/_actions/rehydrateStatusOrNotification.js
Normal file
|
@ -0,0 +1,67 @@
|
|||
import { get } from '../_utils/lodash-lite.js'
|
||||
import { mark, stop } from '../_utils/marks.js'
|
||||
import { decode as decodeBlurhash, init as initBlurhash } from '../_utils/blurhash.js'
|
||||
import { scheduleIdleTask } from '../_utils/scheduleIdleTask.js'
|
||||
import { statusHtmlToPlainText } from '../_utils/statusHtmlToPlainText.js'
|
||||
|
||||
function getActualStatus (statusOrNotification) {
|
||||
return get(statusOrNotification, ['status']) ||
|
||||
get(statusOrNotification, ['notification', 'status'])
|
||||
}
|
||||
|
||||
export function prepareToRehydrate () {
|
||||
// start the blurhash worker a bit early to save time
|
||||
try {
|
||||
initBlurhash()
|
||||
} catch (err) {
|
||||
console.error('could not start blurhash worker', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function decodeAllBlurhashes (statusOrNotification) {
|
||||
const status = getActualStatus(statusOrNotification)
|
||||
if (!status) {
|
||||
return
|
||||
}
|
||||
const mediaWithBlurhashes = get(status, ['media_attachments'], [])
|
||||
.concat(get(status, ['reblog', 'media_attachments'], []))
|
||||
.filter(_ => _.blurhash)
|
||||
if (mediaWithBlurhashes.length) {
|
||||
mark(`decodeBlurhash-${status.id}`)
|
||||
await Promise.all(mediaWithBlurhashes.map(async media => {
|
||||
try {
|
||||
media.decodedBlurhash = await decodeBlurhash(media.blurhash)
|
||||
} catch (err) {
|
||||
console.warn('Could not decode blurhash, ignoring', err)
|
||||
}
|
||||
}))
|
||||
stop(`decodeBlurhash-${status.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function calculatePlainTextContent (statusOrNotification) {
|
||||
const status = getActualStatus(statusOrNotification)
|
||||
if (!status) {
|
||||
return
|
||||
}
|
||||
const originalStatus = status.reblog ? status.reblog : status
|
||||
const content = originalStatus.content || ''
|
||||
const mentions = originalStatus.mentions || []
|
||||
// Calculating the plaintext from the HTML is a non-trivial operation, so we might
|
||||
// as well do it in advance, while blurhash is being decoded on the worker thread.
|
||||
await new Promise(resolve => {
|
||||
scheduleIdleTask(() => {
|
||||
originalStatus.plainTextContent = statusHtmlToPlainText(content, mentions)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Do stuff that we need to do when the status or notification is fetched from the database,
|
||||
// like calculating the blurhash or calculating the plain text content
|
||||
export async function rehydrateStatusOrNotification (statusOrNotification) {
|
||||
await Promise.all([
|
||||
decodeAllBlurhashes(statusOrNotification),
|
||||
calculatePlainTextContent(statusOrNotification)
|
||||
])
|
||||
}
|
|
@ -66,7 +66,7 @@ export async function getTimeline (instanceName, accessToken, timeline, maxId, s
|
|||
}
|
||||
|
||||
if (timeline === 'notifications/mentions') {
|
||||
params.exclude_types = ['follow', 'favourite', 'reblog', 'poll', 'admin.sign_up']
|
||||
params.exclude_types = ['follow', 'favourite', 'reblog', 'poll', 'admin.sign_up', 'update', 'follow_request', 'admin.report']
|
||||
}
|
||||
|
||||
url += '?' + paramsString(params)
|
||||
|
|
|
@ -8,7 +8,10 @@
|
|||
<button type="button"
|
||||
class="dynamic-page-go-back"
|
||||
aria-label="{intl.goBack}"
|
||||
on:click|preventDefault="onGoBack()">{intl.back}</button>
|
||||
on:click|preventDefault="onGoBack()">
|
||||
<SvgIcon className="dynamic-page-go-back-icon" href="#fa-arrow-left" />
|
||||
{intl.back}
|
||||
</button>
|
||||
</div>
|
||||
<Shortcut key="Backspace" on:pressed="onGoBack()"/>
|
||||
<style>
|
||||
|
@ -34,19 +37,25 @@
|
|||
text-overflow: ellipsis;
|
||||
}
|
||||
.dynamic-page-go-back {
|
||||
font-size: 1.3em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-self: flex-end;
|
||||
font-size: 1.2857142857142858em;
|
||||
color: var(--anchor-text);
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
justify-self: flex-end;
|
||||
}
|
||||
.dynamic-page-go-back:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.dynamic-page-go-back::before {
|
||||
content: '←';
|
||||
margin-right: 5px;
|
||||
:global(.dynamic-page-go-back-icon) {
|
||||
position: relative;
|
||||
bottom: 0.06em;
|
||||
margin-right: 0.2em;
|
||||
height: 0.66666666em;
|
||||
width: 0.66666666em;
|
||||
fill: currentColor;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.dynamic-page-banner {
|
||||
|
|
|
@ -76,6 +76,10 @@
|
|||
}
|
||||
if (notificationType === 'admin.sign_up') {
|
||||
return formatIntl('intl.accountSignedUp', params)
|
||||
} else if (notificationType === 'follow_request') {
|
||||
return formatIntl('intl.accountRequestedFollow', params)
|
||||
} else if (notificationType === 'admin.report') {
|
||||
return formatIntl('intl.accountReported', params)
|
||||
} else { // 'follow'
|
||||
return formatIntl('intl.accountFollowedYou', params)
|
||||
}
|
||||
|
|
|
@ -139,6 +139,10 @@
|
|||
return '#fa-user-plus'
|
||||
} else if (notificationType === 'update') {
|
||||
return '#fa-pencil'
|
||||
} else if (notificationType === 'follow_request') {
|
||||
return '#fa-hourglass'
|
||||
} else if (notificationType === 'admin.report') {
|
||||
return '#fa-flag'
|
||||
}
|
||||
return '#fa-star'
|
||||
},
|
||||
|
@ -163,6 +167,10 @@
|
|||
return 'intl.reblogged'
|
||||
} else if (notificationType === 'update') {
|
||||
return 'intl.edited'
|
||||
} else if (notificationType === 'follow_request') {
|
||||
return 'intl.requestedFollow'
|
||||
} else if (notificationType === 'admin.report') {
|
||||
return 'intl.reported'
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
|
|
|
@ -5,6 +5,8 @@ import {
|
|||
} from '../__sapper__/service-worker.js'
|
||||
import { get, post } from './routes/_utils/ajax.js'
|
||||
import { setWebShareData, closeKeyValIDBConnection } from './routes/_database/webShare.js'
|
||||
import { getKnownInstances } from './routes/_database/knownInstances.js'
|
||||
import { basename } from './routes/_api/utils.js'
|
||||
|
||||
const timestamp = process.env.SAPPER_TIMESTAMP
|
||||
const ASSETS = `assets_${timestamp}`
|
||||
|
@ -169,8 +171,18 @@ self.addEventListener('fetch', event => {
|
|||
self.addEventListener('push', event => {
|
||||
event.waitUntil((async () => {
|
||||
const data = event.data.json()
|
||||
const { origin } = event.target
|
||||
// If there is only once instance, then we know for sure that the push notification came from it
|
||||
const knownInstances = await getKnownInstances()
|
||||
if (knownInstances.length !== 1) {
|
||||
// TODO: Mastodon currently does not tell us which instance the push notification came from.
|
||||
// So we have to guess and currently just choose the first one. We _could_ locally store the instance that
|
||||
// currently has push notifications enabled, but this would only work for one instance at a time.
|
||||
// See: https://github.com/mastodon/mastodon/issues/22183
|
||||
await showSimpleNotification(data)
|
||||
return
|
||||
}
|
||||
|
||||
const origin = basename(knownInstances[0])
|
||||
try {
|
||||
const notification = await get(`${origin}/api/v1/notifications/${data.notification_id}`, {
|
||||
Authorization: `Bearer ${data.access_token}`
|
||||
|
@ -203,6 +215,8 @@ async function showRichNotification (data, notification) {
|
|||
|
||||
switch (notification.type) {
|
||||
case 'follow':
|
||||
case 'follow_request':
|
||||
case 'admin.report':
|
||||
case 'admin.sign_up': {
|
||||
await self.registration.showNotification(data.title, {
|
||||
badge,
|
||||
|
|
|
@ -1 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2048 1792"><path fill="#fff" d="M1344 1504q0 13-9 23t-23 9H352q-8 0-13-2t-9-7-6-8-3-11-1-12V896H128q-26 0-45-19t-19-45q0-24 15-41l320-384q19-22 49-22t49 22l320 384q15 17 15 41 0 26-19 45t-45 19H576v384h576q16 0 25 11l160 192q7 10 7 21zm640-416q0 24-15 41l-320 384q-20 23-49 23t-49-23l-320-384q-15-17-15-41 0-26 19-45t45-19h192V640H896q-16 0-25-12L711 436q-7-9-7-20 0-13 10-22t22-10h960q8 0 14 2t9 7 5 8 3 12 1 11v600h192q26 0 45 19t19 45z"/></svg>
|
||||
<svg viewBox="0 0 1792 1792" width="1792" height="1792" xmlns:svg="http://www.w3.org/2000/svg"><path fill="#fff" d="m 384.00001,344.0625 a 71.966714,71.966714 0 0 0 -56.18749,27 l -256.000008,320 a 71.966714,71.966714 0 0 0 56.187498,116.875 h 104.0625 V 1376 a 71.966714,71.966714 0 0 0 71.9375,71.9375 H 1024 a 71.966714,71.966714 0 0 0 56.1875,-116.875 l -128,-160 a 71.966714,71.966714 0 0 0 -56.18749,-27 h -360.0625 v -336.125 h 104.0625 a 71.966714,71.966714 0 0 0 56.18748,-116.875 l -256,-320 a 71.966714,71.966714 0 0 0 -56.18748,-27 z m 384,0 a 71.966714,71.966714 0 0 0 -56.18749,116.875 l 128,160 a 71.966714,71.966714 0 0 0 56.18749,27 h 360.06249 v 336.125 H 1152 a 71.966714,71.966714 0 0 0 -56.1875,116.875 l 256,320 a 71.966714,71.966714 0 0 0 112.375,0 l 256,-320 A 71.966714,71.966714 0 0 0 1664,984.0625 H 1559.9375 V 416 A 71.966714,71.966714 0 0 0 1488,344.0625 Z" /></svg>
|
||||
|
|
Before Width: | Height: | Size: 500 B After Width: | Height: | Size: 897 B |
|
@ -2,8 +2,16 @@ import { loginAsLockedAccount } from '../roles'
|
|||
import { followAs, unfollowAs } from '../serverActions'
|
||||
import {
|
||||
avatarInComposeBox,
|
||||
communityNavButton, followersButton, getNthSearchResult, getSearchResultByHref, getUrl, goBack,
|
||||
homeNavButton, sleep
|
||||
communityNavButton,
|
||||
followersButton,
|
||||
getNthSearchResult,
|
||||
getNthStatus,
|
||||
getSearchResultByHref,
|
||||
getUrl,
|
||||
goBack,
|
||||
homeNavButton,
|
||||
notificationsNavButton,
|
||||
sleep
|
||||
} from '../utils'
|
||||
import { users } from '../users'
|
||||
import { Selector as $ } from 'testcafe'
|
||||
|
@ -93,6 +101,9 @@ test('Shows unresolved follow requests', async t => {
|
|||
|
||||
await t
|
||||
.expect(communityNavButton.getAttribute('aria-label')).eql('Community (2 follow requests)')
|
||||
.click(notificationsNavButton)
|
||||
.expect(getUrl()).contains('/notifications')
|
||||
.expect(getNthStatus(1).innerText).contains('requested to follow you')
|
||||
.click(communityNavButton)
|
||||
.expect(requestsButton.innerText).contains('Follow requests (2)')
|
||||
.click(requestsButton)
|
||||
|
|
|
@ -4,10 +4,10 @@ import {
|
|||
getNthPinnedStatusFavoriteButton,
|
||||
getNthStatus, getNthStatusContent,
|
||||
getNthStatusOptionsButton, getUrl, homeNavButton, postStatusButton, scrollToTop, scrollToBottom,
|
||||
settingsNavButton, sleep
|
||||
settingsNavButton, sleep, getNthStatusAccountLink
|
||||
} from '../utils'
|
||||
import { users } from '../users'
|
||||
import { postAs } from '../serverActions'
|
||||
import { postAs, postStatusWithMediaAs } from '../serverActions'
|
||||
|
||||
fixture`117-pin-unpin.js`
|
||||
.page`http://localhost:4002`
|
||||
|
@ -84,3 +84,22 @@ test('Saved pinned/unpinned state of status', async t => {
|
|||
.click(getNthStatusOptionsButton(1))
|
||||
.expect(getNthDialogOptionsOption(2).innerText).contains('Unpin from profile', { timeout })
|
||||
})
|
||||
|
||||
test('pinned posts and aria-labels', async t => {
|
||||
const timeout = 20000
|
||||
await postStatusWithMediaAs('foobar', 'here is a sensitive kitty', 'kitten2.jpg', 'kitten', true)
|
||||
await loginAsFoobar(t)
|
||||
await t
|
||||
.expect(getNthStatusContent(1).innerText).contains('here is a sensitive kitty', { timeout })
|
||||
.click(getNthStatusOptionsButton(1))
|
||||
.expect(getNthDialogOptionsOption(2).innerText).contains('Pin to profile')
|
||||
.click(getNthDialogOptionsOption(2))
|
||||
.click(getNthStatusAccountLink(1))
|
||||
.expect(getNthPinnedStatus(1).getAttribute('aria-label')).match(
|
||||
/foobar, here is a sensitive kitty, has media, (.+ ago|just now), @foobar, Public/i
|
||||
)
|
||||
.expect(getNthStatusContent(1).innerText).contains('here is a sensitive kitty')
|
||||
.click(getNthStatusOptionsButton(1))
|
||||
.expect(getNthDialogOptionsOption(2).innerText).contains('Unpin from profile')
|
||||
await sleep(2000)
|
||||
})
|
||||
|
|
Loading…
Add table
Reference in a new issue