Compare commits

...

10 commits

Author SHA1 Message Date
Reynir Björnsson
61fc11a65e Only build on new tag
Otherwise we might build twice
2022-12-12 09:15:02 +01:00
Reynir Björnsson
685572bcd8 Add .drone.yml 2022-12-12 09:15:02 +01:00
Nolan Lawson
774aa7a21c 2.5.0 2022-12-11 14:49:35 -08:00
Nolan Lawson
276c6e7bea
fix: show text for report notifications (#2318)
Fixes #2315
2022-12-11 13:09:12 -08:00
Nolan Lawson
f61054a3d5
test: add test for #2263 (#2317) 2022-12-11 12:46:59 -08:00
Nolan Lawson
b1dc43a9c9
fix: show proper notification text for follow request (#2314)
Fixes #1800
2022-12-11 12:01:01 -08:00
Nolan Lawson
040462f5b5
fix: fix pinned status aria-label/blurhash (#2313)
Fixes #2294
2022-12-11 11:00:45 -08:00
Thomas Broyer
f5f3395a53
fix: fix rich push notifications for single-instance situations (#2296)
Partially addresses #1663.

Co-authored-by: Nolan Lawson <nolan@nolanlawson.com>
2022-12-10 15:48:29 -08:00
Nick Colley
3fb152ac7c
fix: back button icon rendering inconsistently (#2306)
Depending on the operating system and therefore the system font
the back icon being a unicode arrow means it'll render inconsistently,
sometimes I've seen it looking really odd.

Instead make use of the font awesome arrow so that'll it render consistently
no matter ths system font.
2022-12-10 23:30:43 +00:00
Daniel Soohan Park
97e3b04f1f
fix: redesigned boost icon to fix alignment (#916) (#2297) 2022-12-10 14:50:46 -08:00
15 changed files with 199 additions and 77 deletions

33
.drone.yml Normal file
View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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)
])
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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