feat: implement notification filters (all vs mentions) (#1177)
fixes #1176
This commit is contained in:
parent
ff1e9e2c41
commit
23bdc6c87e
|
@ -6,15 +6,22 @@ import { addStatusOrNotification } from './addStatusOrNotification'
|
|||
function processMessage (instanceName, timelineName, message) {
|
||||
mark('processMessage')
|
||||
let { event, payload } = message
|
||||
if (['update', 'notification', 'conversation'].includes(event)) {
|
||||
payload = JSON.parse(payload) // only these payloads are JSON-encoded for some reason
|
||||
}
|
||||
|
||||
switch (event) {
|
||||
case 'delete':
|
||||
deleteStatus(instanceName, payload)
|
||||
break
|
||||
case 'update':
|
||||
addStatusOrNotification(instanceName, timelineName, JSON.parse(payload))
|
||||
addStatusOrNotification(instanceName, timelineName, payload)
|
||||
break
|
||||
case 'notification':
|
||||
addStatusOrNotification(instanceName, 'notifications', JSON.parse(payload))
|
||||
addStatusOrNotification(instanceName, 'notifications', payload)
|
||||
if (payload.type === 'mention') {
|
||||
addStatusOrNotification(instanceName, 'notifications/mentions', payload)
|
||||
}
|
||||
break
|
||||
case 'conversation':
|
||||
// This is a hack in order to mostly fit the conversation model into
|
||||
|
@ -22,7 +29,7 @@ function processMessage (instanceName, timelineName, message) {
|
|||
// reproduce what is done for statuses for the conversation.
|
||||
//
|
||||
// It will add new DMs as new conversations instead of updating existing threads
|
||||
addStatusOrNotification(instanceName, timelineName, JSON.parse(payload).last_status)
|
||||
addStatusOrNotification(instanceName, timelineName, payload.last_status)
|
||||
break
|
||||
}
|
||||
stop('processMessage')
|
||||
|
|
|
@ -9,6 +9,7 @@ function getTimelineUrlPath (timeline) {
|
|||
case 'home':
|
||||
return 'timelines/home'
|
||||
case 'notifications':
|
||||
case 'notifications/mentions':
|
||||
return 'notifications'
|
||||
case 'favorites':
|
||||
return 'favourites'
|
||||
|
@ -61,6 +62,10 @@ export async function getTimeline (instanceName, accessToken, timeline, maxId, s
|
|||
}
|
||||
}
|
||||
|
||||
if (timeline === 'notifications/mentions') {
|
||||
params.exclude_types = ['follow', 'favourite', 'reblog', 'poll']
|
||||
}
|
||||
|
||||
url += '?' + paramsString(params)
|
||||
|
||||
const items = await get(url, auth(accessToken), { timeout: DEFAULT_TIMEOUT })
|
||||
|
|
|
@ -146,7 +146,11 @@
|
|||
},
|
||||
store: () => store,
|
||||
computed: {
|
||||
selected: ({ page, name }) => page === name,
|
||||
selected: ({ page, name }) => {
|
||||
return page === name ||
|
||||
// special case – these should both highlight the notifications tab icon
|
||||
(name === 'notifications' && page === 'notifications/mentions')
|
||||
},
|
||||
ariaLabel: ({ selected, name, label, $numberOfNotifications }) => {
|
||||
let res = label
|
||||
if (selected) {
|
||||
|
|
29
src/routes/_components/NotificationFilters.html
Normal file
29
src/routes/_components/NotificationFilters.html
Normal file
|
@ -0,0 +1,29 @@
|
|||
<TabSet
|
||||
label="Filters"
|
||||
currentTabName={filter}
|
||||
{tabs}
|
||||
className="notification-filters"
|
||||
/>
|
||||
<script>
|
||||
import TabSet from './TabSet.html'
|
||||
|
||||
export default {
|
||||
data: () => ({
|
||||
tabs: [
|
||||
{
|
||||
name: '',
|
||||
label: 'All',
|
||||
href: `/notifications`
|
||||
},
|
||||
{
|
||||
name: 'mentions',
|
||||
label: 'Mentions',
|
||||
href: `/notifications/mentions`
|
||||
}
|
||||
]
|
||||
}),
|
||||
components: {
|
||||
TabSet
|
||||
}
|
||||
}
|
||||
</script>
|
89
src/routes/_components/TabSet.html
Normal file
89
src/routes/_components/TabSet.html
Normal file
|
@ -0,0 +1,89 @@
|
|||
<nav aria-label={label} class={className}>
|
||||
<ul>
|
||||
{#each tabs as tab (tab.name)}
|
||||
<li class="{currentTabName === tab.name ? 'current' : 'not-current'}">
|
||||
<a aria-label="{tab.label} { currentTabName === tab.name ? '(Current)' : ''}"
|
||||
href={tab.href}
|
||||
rel="prefetch">
|
||||
{tab.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
<style>
|
||||
li {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* reset */
|
||||
ul, li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
margin: 5px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
li {
|
||||
border: 1px solid var(--main-border);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-top-left-radius: 10px;
|
||||
border-top-right-radius: 10px;
|
||||
background: var(--tab-bg);
|
||||
}
|
||||
|
||||
li:not(:first-child) {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
li:hover {
|
||||
background: var(--button-bg-hover);
|
||||
}
|
||||
|
||||
li.not-current {
|
||||
background: var(--tab-bg-non-selected);
|
||||
}
|
||||
|
||||
li.current {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
li.current:hover {
|
||||
background: var(--tab-bg-hover);
|
||||
}
|
||||
|
||||
li.not-current:hover {
|
||||
background: var(--tab-bg-hover-non-selected);
|
||||
}
|
||||
|
||||
li:active {
|
||||
background: var(--tab-bg-active);
|
||||
}
|
||||
|
||||
a {
|
||||
padding: 10px;
|
||||
color: var(--body-text-color);
|
||||
font-size: 1.1em;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
export default {
|
||||
data: () => ({
|
||||
className: ''
|
||||
})
|
||||
}
|
||||
</script>
|
|
@ -1,107 +1,36 @@
|
|||
<nav aria-label="Filters" class="account-profile-filters">
|
||||
<ul>
|
||||
{#each filterTabs as filterTab (filterTab.href)}
|
||||
<li class="{filter === filterTab.filter ? 'current-filter' : 'not-current-filter'}">
|
||||
<a aria-label="{filterTab.label} { filter === filterTab.filter ? '(Current)' : ''}"
|
||||
href={filterTab.href}
|
||||
rel="prefetch">
|
||||
{filterTab.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
<style>
|
||||
li {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* reset */
|
||||
ul, li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
margin: 5px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
li {
|
||||
border: 1px solid var(--main-border);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-top-left-radius: 10px;
|
||||
border-top-right-radius: 10px;
|
||||
background: var(--tab-bg);
|
||||
}
|
||||
|
||||
li:not(:first-child) {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
li:hover {
|
||||
background: var(--button-bg-hover);
|
||||
}
|
||||
|
||||
li.not-current-filter {
|
||||
background: var(--tab-bg-non-selected);
|
||||
}
|
||||
|
||||
li.current-filter {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
li.current-filter:hover {
|
||||
background: var(--tab-bg-hover);
|
||||
}
|
||||
|
||||
li.not-current-filter:hover {
|
||||
background: var(--tab-bg-hover-non-selected);
|
||||
}
|
||||
|
||||
li:active {
|
||||
background: var(--tab-bg-active);
|
||||
}
|
||||
|
||||
a {
|
||||
padding: 10px;
|
||||
color: var(--body-text-color);
|
||||
font-size: 1.1em;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
<TabSet
|
||||
label="Filters"
|
||||
currentTabName={filter}
|
||||
{tabs}
|
||||
className="account-profile-filters"
|
||||
/>
|
||||
<script>
|
||||
import TabSet from '../TabSet.html'
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
filterTabs: ({ account }) => (
|
||||
tabs: ({ account }) => (
|
||||
[
|
||||
{
|
||||
filter: '',
|
||||
name: '',
|
||||
label: 'Toots',
|
||||
href: `/accounts/${account.id}`
|
||||
},
|
||||
{
|
||||
filter: 'with_replies',
|
||||
name: 'with_replies',
|
||||
label: 'Toots and replies',
|
||||
href: `/accounts/${account.id}/with_replies`
|
||||
},
|
||||
{
|
||||
filter: 'media',
|
||||
name: 'media',
|
||||
label: 'Media',
|
||||
href: `/accounts/${account.id}/media`
|
||||
}
|
||||
]
|
||||
)
|
||||
},
|
||||
components: {
|
||||
TabSet
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -111,7 +111,7 @@ async function insertStatusThread (instanceName, statusId, statuses) {
|
|||
|
||||
export async function insertTimelineItems (instanceName, timeline, timelineItems) {
|
||||
/* no await */ scheduleCleanup()
|
||||
if (timeline === 'notifications') {
|
||||
if (timeline === 'notifications' || timeline === 'notifications/mentions') {
|
||||
return insertTimelineNotifications(instanceName, timeline, timelineItems)
|
||||
} else if (timeline.startsWith('status/')) {
|
||||
let statusId = timeline.split('/').slice(-1)[0]
|
||||
|
|
|
@ -84,7 +84,7 @@ async function getStatusThread (instanceName, statusId) {
|
|||
export async function getTimeline (instanceName, timeline, maxId, limit) {
|
||||
maxId = maxId || null
|
||||
limit = limit || TIMELINE_BATCH_SIZE
|
||||
if (timeline === 'notifications') {
|
||||
if (timeline === 'notifications' || timeline === 'notifications/mentions') {
|
||||
return getNotificationTimeline(instanceName, timeline, maxId, limit)
|
||||
} else if (timeline.startsWith('status/')) {
|
||||
let statusId = timeline.split('/').slice(-1)[0]
|
||||
|
|
|
@ -90,6 +90,7 @@
|
|||
<a href="/federated">Federated</a>
|
||||
<a href="/favorites">Favorites</a>
|
||||
<a href="/direct">Conversations</a>
|
||||
<a href="/notifications/mentions">Notification mentions</a>
|
||||
</div>
|
||||
{/if}
|
||||
<style>
|
||||
|
|
|
@ -1,26 +0,0 @@
|
|||
{#if $isUserLoggedIn}
|
||||
<TimelinePage timeline="notifications" />
|
||||
{:else}
|
||||
<HiddenFromSSR>
|
||||
<FreeTextLayout>
|
||||
<h1>Notifications</h1>
|
||||
|
||||
<p>Your notifications will appear here when logged in.</p>
|
||||
</FreeTextLayout>
|
||||
</HiddenFromSSR>
|
||||
{/if}
|
||||
<script>
|
||||
import FreeTextLayout from '../_components/FreeTextLayout.html'
|
||||
import { store } from '../_store/store.js'
|
||||
import HiddenFromSSR from '../_components/HiddenFromSSR'
|
||||
import TimelinePage from '../_components/TimelinePage.html'
|
||||
|
||||
export default {
|
||||
store: () => store,
|
||||
components: {
|
||||
FreeTextLayout,
|
||||
HiddenFromSSR,
|
||||
TimelinePage
|
||||
}
|
||||
}
|
||||
</script>
|
29
src/routes/_pages/notifications/index.html
Normal file
29
src/routes/_pages/notifications/index.html
Normal file
|
@ -0,0 +1,29 @@
|
|||
{#if $isUserLoggedIn}
|
||||
<NotificationFilters filter="" />
|
||||
<TimelinePage timeline="notifications" />
|
||||
{:else}
|
||||
<HiddenFromSSR>
|
||||
<FreeTextLayout>
|
||||
<h1>Notifications</h1>
|
||||
|
||||
<p>Your notifications will appear here when logged in.</p>
|
||||
</FreeTextLayout>
|
||||
</HiddenFromSSR>
|
||||
{/if}
|
||||
<script>
|
||||
import FreeTextLayout from '../../_components/FreeTextLayout.html'
|
||||
import { store } from '../../_store/store.js'
|
||||
import HiddenFromSSR from '../../_components/HiddenFromSSR'
|
||||
import TimelinePage from '../../_components/TimelinePage.html'
|
||||
import NotificationFilters from '../../_components/NotificationFilters.html'
|
||||
|
||||
export default {
|
||||
store: () => store,
|
||||
components: {
|
||||
FreeTextLayout,
|
||||
HiddenFromSSR,
|
||||
TimelinePage,
|
||||
NotificationFilters
|
||||
}
|
||||
}
|
||||
</script>
|
29
src/routes/_pages/notifications/mentions.html
Normal file
29
src/routes/_pages/notifications/mentions.html
Normal file
|
@ -0,0 +1,29 @@
|
|||
{#if $isUserLoggedIn}
|
||||
<NotificationFilters filter="mentions" />
|
||||
<TimelinePage timeline="notifications/mentions" />
|
||||
{:else}
|
||||
<HiddenFromSSR>
|
||||
<FreeTextLayout>
|
||||
<h1>Notification mentions</h1>
|
||||
|
||||
<p>Your notification mentions will appear here when logged in.</p>
|
||||
</FreeTextLayout>
|
||||
</HiddenFromSSR>
|
||||
{/if}
|
||||
<script>
|
||||
import FreeTextLayout from '../../_components/FreeTextLayout.html'
|
||||
import { store } from '../../_store/store.js'
|
||||
import HiddenFromSSR from '../../_components/HiddenFromSSR'
|
||||
import TimelinePage from '../../_components/TimelinePage.html'
|
||||
import NotificationFilters from '../../_components/NotificationFilters.html'
|
||||
|
||||
export default {
|
||||
store: () => store,
|
||||
components: {
|
||||
FreeTextLayout,
|
||||
HiddenFromSSR,
|
||||
TimelinePage,
|
||||
NotificationFilters
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -74,11 +74,16 @@ export async function del (url, headers, options) {
|
|||
|
||||
export function paramsString (paramsObject) {
|
||||
let res = ''
|
||||
Object.keys(paramsObject).forEach((key, i) => {
|
||||
if (i > 0) {
|
||||
res += '&'
|
||||
let count = -1
|
||||
Object.keys(paramsObject).forEach(key => {
|
||||
let value = paramsObject[key]
|
||||
if (Array.isArray(value)) { // rails convention for encoding multiple values
|
||||
for (let item of value) {
|
||||
res += (++count > 0 ? '&' : '') + encodeURIComponent(key) + '[]=' + encodeURIComponent(item)
|
||||
}
|
||||
} else {
|
||||
res += (++count > 0 ? '&' : '') + encodeURIComponent(key) + '=' + encodeURIComponent(value)
|
||||
}
|
||||
res += encodeURIComponent(key) + '=' + encodeURIComponent(paramsObject[key])
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
|
|
@ -3,9 +3,9 @@
|
|||
<LazyPage {pageComponent} {params} />
|
||||
|
||||
<script>
|
||||
import Title from './_components/Title.html'
|
||||
import LazyPage from './_components/LazyPage.html'
|
||||
import pageComponent from './_pages/notifications.html'
|
||||
import Title from '../_components/Title.html'
|
||||
import LazyPage from '../_components/LazyPage.html'
|
||||
import pageComponent from '../_pages/notifications/index.html'
|
||||
|
||||
export default {
|
||||
components: {
|
20
src/routes/notifications/mentions.html
Normal file
20
src/routes/notifications/mentions.html
Normal file
|
@ -0,0 +1,20 @@
|
|||
<Title name="Notifications" />
|
||||
|
||||
<LazyPage {pageComponent} {params} />
|
||||
|
||||
<script>
|
||||
import Title from '../_components/Title.html'
|
||||
import LazyPage from '../_components/LazyPage.html'
|
||||
import pageComponent from '../_pages/notifications/mentions.html'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
Title,
|
||||
LazyPage
|
||||
},
|
||||
data: () => ({
|
||||
pageComponent
|
||||
})
|
||||
}
|
||||
</script>
|
|
@ -44,6 +44,13 @@ export const notifications = [
|
|||
{ followedBy: 'admin' }
|
||||
]
|
||||
|
||||
export const notificationsMentions = [
|
||||
{ content: 'notification of unlisted message' },
|
||||
{ content: 'notification of followers-only message' },
|
||||
{ content: 'notification of direct message' },
|
||||
{ content: 'hello foobar' }
|
||||
]
|
||||
|
||||
export const favorites = [
|
||||
{ content: 'notification of direct message' },
|
||||
{ content: 'notification of followers-only message' },
|
||||
|
|
22
tests/spec/033-notification-filters.js
Normal file
22
tests/spec/033-notification-filters.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
import {
|
||||
getUrl, notificationFiltersAll, notificationFiltersMention,
|
||||
notificationsNavButton, validateTimeline
|
||||
} from '../utils'
|
||||
import { loginAsFoobar } from '../roles'
|
||||
import { notificationsMentions, notifications } from '../fixtures'
|
||||
|
||||
fixture`033-notification-filters.js`
|
||||
.page`http://localhost:4002`
|
||||
|
||||
test('Shows notification filters', async t => {
|
||||
await loginAsFoobar(t)
|
||||
await t
|
||||
.click(notificationsNavButton)
|
||||
.expect(getUrl()).match(/\/notifications$/)
|
||||
.click(notificationFiltersMention)
|
||||
.expect(getUrl()).match(/\/notifications\/mentions$/)
|
||||
await validateTimeline(t, notificationsMentions)
|
||||
await t.click(notificationFiltersAll)
|
||||
.expect(getUrl()).match(/\/notifications$/)
|
||||
await validateTimeline(t, notifications)
|
||||
})
|
65
tests/spec/123-notification-filters.js
Normal file
65
tests/spec/123-notification-filters.js
Normal file
|
@ -0,0 +1,65 @@
|
|||
import {
|
||||
getNthStatusContent,
|
||||
getUrl, notificationFiltersAll, notificationFiltersMention,
|
||||
notificationsNavButton, sleep
|
||||
} from '../utils'
|
||||
import { loginAsFoobar } from '../roles'
|
||||
import { favoriteStatusAs, postAs } from '../serverActions'
|
||||
|
||||
fixture`123-notification-filters.js`
|
||||
.page`http://localhost:4002`
|
||||
|
||||
// maybe in the "mentions" view it should prevent the notification icon from showing (1), (2) etc
|
||||
// if those particular notifications were seen by the user... but this is too hard to implement,
|
||||
// so I'm going to punt on it. Only the "all" view affects those (1) / (2) / etc badges.
|
||||
test('Handles incoming notifications that are mentions', async t => {
|
||||
const timeout = 20000
|
||||
await loginAsFoobar(t)
|
||||
await t
|
||||
.click(notificationsNavButton)
|
||||
.expect(getUrl()).match(/\/notifications$/)
|
||||
.click(notificationFiltersMention)
|
||||
.expect(getUrl()).match(/\/notifications\/mentions$/)
|
||||
await sleep(2000)
|
||||
await postAs('admin', 'hey @foobar I am mentioning you')
|
||||
await t
|
||||
.expect(notificationsNavButton.getAttribute('aria-label')).eql('Notifications (current page) (1 notification)', {
|
||||
timeout
|
||||
})
|
||||
.expect(getNthStatusContent(1).innerText).contains('hey @foobar I am mentioning you')
|
||||
.click(notificationFiltersAll)
|
||||
.expect(notificationsNavButton.getAttribute('aria-label')).eql('Notifications (current page)', { timeout })
|
||||
})
|
||||
|
||||
test('Handles incoming notifications that are not mentions', async t => {
|
||||
const timeout = 20000
|
||||
let { id: statusId } = await postAs('foobar', 'this is a post that I hope somebody will favorite')
|
||||
await sleep(2000)
|
||||
await loginAsFoobar(t)
|
||||
await t
|
||||
.click(notificationsNavButton)
|
||||
.expect(getUrl()).match(/\/notifications$/)
|
||||
.click(notificationFiltersMention)
|
||||
.expect(getUrl()).match(/\/notifications\/mentions$/)
|
||||
await sleep(2000)
|
||||
await postAs('admin', 'woot I am mentioning you again @foobar')
|
||||
await t
|
||||
.expect(notificationsNavButton.getAttribute('aria-label')).eql('Notifications (current page) (1 notification)', {
|
||||
timeout
|
||||
})
|
||||
.expect(getNthStatusContent(1).innerText).contains('woot I am mentioning you again @foobar')
|
||||
await sleep(2000)
|
||||
await favoriteStatusAs('admin', statusId)
|
||||
await t
|
||||
.expect(notificationsNavButton.getAttribute('aria-label')).eql('Notifications (current page) (2 notifications)', {
|
||||
timeout
|
||||
})
|
||||
await sleep(2000)
|
||||
await t
|
||||
.expect(getNthStatusContent(1).innerText).contains('woot I am mentioning you again @foobar')
|
||||
.click(notificationFiltersAll)
|
||||
.expect(notificationsNavButton.getAttribute('aria-label')).eql('Notifications (current page)', { timeout })
|
||||
await t
|
||||
.expect(getNthStatusContent(1).innerText).contains('this is a post that I hope somebody will favorite')
|
||||
.expect(getNthStatusContent(2).innerText).contains('woot I am mentioning you again @foobar')
|
||||
})
|
|
@ -64,6 +64,9 @@ export const accountProfileFilterStatuses = $('.account-profile-filters li:nth-c
|
|||
export const accountProfileFilterStatusesAndReplies = $('.account-profile-filters li:nth-child(2)')
|
||||
export const accountProfileFilterMedia = $('.account-profile-filters li:nth-child(3)')
|
||||
|
||||
export const notificationFiltersAll = $('.notification-filters li:nth-child(1)')
|
||||
export const notificationFiltersMention = $('.notification-filters li:nth-child(2)')
|
||||
|
||||
export function getComposeModalNthMediaAltInput (n) {
|
||||
return $(`.modal-dialog .compose-media:nth-child(${n}) .compose-media-alt input`)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue