feat: implement notification filters (all vs mentions) (#1177)

fixes #1176
This commit is contained in:
Nolan Lawson 2019-05-04 17:58:44 -07:00 committed by GitHub
parent ff1e9e2c41
commit 23bdc6c87e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 343 additions and 125 deletions

View file

@ -6,15 +6,22 @@ import { addStatusOrNotification } from './addStatusOrNotification'
function processMessage (instanceName, timelineName, message) { function processMessage (instanceName, timelineName, message) {
mark('processMessage') mark('processMessage')
let { event, payload } = message 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) { switch (event) {
case 'delete': case 'delete':
deleteStatus(instanceName, payload) deleteStatus(instanceName, payload)
break break
case 'update': case 'update':
addStatusOrNotification(instanceName, timelineName, JSON.parse(payload)) addStatusOrNotification(instanceName, timelineName, payload)
break break
case 'notification': case 'notification':
addStatusOrNotification(instanceName, 'notifications', JSON.parse(payload)) addStatusOrNotification(instanceName, 'notifications', payload)
if (payload.type === 'mention') {
addStatusOrNotification(instanceName, 'notifications/mentions', payload)
}
break break
case 'conversation': case 'conversation':
// This is a hack in order to mostly fit the conversation model into // 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. // reproduce what is done for statuses for the conversation.
// //
// It will add new DMs as new conversations instead of updating existing threads // 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 break
} }
stop('processMessage') stop('processMessage')

View file

@ -9,6 +9,7 @@ function getTimelineUrlPath (timeline) {
case 'home': case 'home':
return 'timelines/home' return 'timelines/home'
case 'notifications': case 'notifications':
case 'notifications/mentions':
return 'notifications' return 'notifications'
case 'favorites': case 'favorites':
return 'favourites' 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) url += '?' + paramsString(params)
const items = await get(url, auth(accessToken), { timeout: DEFAULT_TIMEOUT }) const items = await get(url, auth(accessToken), { timeout: DEFAULT_TIMEOUT })

View file

@ -146,7 +146,11 @@
}, },
store: () => store, store: () => store,
computed: { 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 }) => { ariaLabel: ({ selected, name, label, $numberOfNotifications }) => {
let res = label let res = label
if (selected) { if (selected) {

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

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

View file

@ -1,107 +1,36 @@
<nav aria-label="Filters" class="account-profile-filters"> <TabSet
<ul> label="Filters"
{#each filterTabs as filterTab (filterTab.href)} currentTabName={filter}
<li class="{filter === filterTab.filter ? 'current-filter' : 'not-current-filter'}"> {tabs}
<a aria-label="{filterTab.label} { filter === filterTab.filter ? '(Current)' : ''}" className="account-profile-filters"
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>
<script> <script>
import TabSet from '../TabSet.html'
export default { export default {
computed: { computed: {
filterTabs: ({ account }) => ( tabs: ({ account }) => (
[ [
{ {
filter: '', name: '',
label: 'Toots', label: 'Toots',
href: `/accounts/${account.id}` href: `/accounts/${account.id}`
}, },
{ {
filter: 'with_replies', name: 'with_replies',
label: 'Toots and replies', label: 'Toots and replies',
href: `/accounts/${account.id}/with_replies` href: `/accounts/${account.id}/with_replies`
}, },
{ {
filter: 'media', name: 'media',
label: 'Media', label: 'Media',
href: `/accounts/${account.id}/media` href: `/accounts/${account.id}/media`
} }
] ]
) )
},
components: {
TabSet
} }
} }
</script> </script>

View file

@ -111,7 +111,7 @@ async function insertStatusThread (instanceName, statusId, statuses) {
export async function insertTimelineItems (instanceName, timeline, timelineItems) { export async function insertTimelineItems (instanceName, timeline, timelineItems) {
/* no await */ scheduleCleanup() /* no await */ scheduleCleanup()
if (timeline === 'notifications') { if (timeline === 'notifications' || timeline === 'notifications/mentions') {
return insertTimelineNotifications(instanceName, timeline, timelineItems) return insertTimelineNotifications(instanceName, timeline, timelineItems)
} else if (timeline.startsWith('status/')) { } else if (timeline.startsWith('status/')) {
let statusId = timeline.split('/').slice(-1)[0] let statusId = timeline.split('/').slice(-1)[0]

View file

@ -84,7 +84,7 @@ async function getStatusThread (instanceName, statusId) {
export async function getTimeline (instanceName, timeline, maxId, limit) { export async function getTimeline (instanceName, timeline, maxId, limit) {
maxId = maxId || null maxId = maxId || null
limit = limit || TIMELINE_BATCH_SIZE limit = limit || TIMELINE_BATCH_SIZE
if (timeline === 'notifications') { if (timeline === 'notifications' || timeline === 'notifications/mentions') {
return getNotificationTimeline(instanceName, timeline, maxId, limit) return getNotificationTimeline(instanceName, timeline, maxId, limit)
} else if (timeline.startsWith('status/')) { } else if (timeline.startsWith('status/')) {
let statusId = timeline.split('/').slice(-1)[0] let statusId = timeline.split('/').slice(-1)[0]

View file

@ -90,6 +90,7 @@
<a href="/federated">Federated</a> <a href="/federated">Federated</a>
<a href="/favorites">Favorites</a> <a href="/favorites">Favorites</a>
<a href="/direct">Conversations</a> <a href="/direct">Conversations</a>
<a href="/notifications/mentions">Notification mentions</a>
</div> </div>
{/if} {/if}
<style> <style>

View file

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

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

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

View file

@ -74,11 +74,16 @@ export async function del (url, headers, options) {
export function paramsString (paramsObject) { export function paramsString (paramsObject) {
let res = '' let res = ''
Object.keys(paramsObject).forEach((key, i) => { let count = -1
if (i > 0) { Object.keys(paramsObject).forEach(key => {
res += '&' 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 return res
} }

View file

@ -3,9 +3,9 @@
<LazyPage {pageComponent} {params} /> <LazyPage {pageComponent} {params} />
<script> <script>
import Title from './_components/Title.html' import Title from '../_components/Title.html'
import LazyPage from './_components/LazyPage.html' import LazyPage from '../_components/LazyPage.html'
import pageComponent from './_pages/notifications.html' import pageComponent from '../_pages/notifications/index.html'
export default { export default {
components: { components: {

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

View file

@ -44,6 +44,13 @@ export const notifications = [
{ followedBy: 'admin' } { 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 = [ export const favorites = [
{ content: 'notification of direct message' }, { content: 'notification of direct message' },
{ content: 'notification of followers-only message' }, { content: 'notification of followers-only message' },

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

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

View file

@ -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 accountProfileFilterStatusesAndReplies = $('.account-profile-filters li:nth-child(2)')
export const accountProfileFilterMedia = $('.account-profile-filters li:nth-child(3)') 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) { export function getComposeModalNthMediaAltInput (n) {
return $(`.modal-dialog .compose-media:nth-child(${n}) .compose-media-alt input`) return $(`.modal-dialog .compose-media:nth-child(${n}) .compose-media-alt input`)
} }