more work on profile page

This commit is contained in:
Nolan Lawson 2018-01-28 12:51:48 -08:00
parent 5e7438cb52
commit 88d59678f2
14 changed files with 309 additions and 205 deletions

View File

@ -1,20 +1,26 @@
<div class="account-profile {{headerIsMissing ? 'header-is-missing' : ''}}" style="background-image: url({{profile.header}});">
<div class="account-profile-grid">
<div class="account-profile-avatar">
<img src="{{profile.avatar}}">
<img src="{{profile.avatar}}" aria-hidden="true">
</div>
<div class="account-profile-name">
{{profile.display_name}}
</div>
<div class="account-profile-following">
<div class="account-profile-followed-by">
{{#if relationship && relationship.followed_by}}
<span>
Follows you
</span>
{{/if}}
</div>
<div class="account-profile-follow">
<svg>
<use xlink:href="#fa-user-plus" />
</svg>
{{#if verifyCredentials && relationship && verifyCredentials.id !== relationship.id}}
<IconButton
label="{{relationship && relationship.following ? 'Unfollow' : 'Follow'}}"
href="{{relationship && relationship.following ? '#fa-user-times' : '#fa-user-plus'}}"
big="true"
/>
{{/if}}
</div>
<div class="account-profile-note">
{{{profile.note}}}
@ -32,6 +38,7 @@
.account-profile.header-is-missing {
padding-top: 0;
background-color: #ccc;
}
.account-profile-background {
@ -45,7 +52,7 @@
.account-profile-grid {
display: grid;
grid-template-areas: "avatar name following follow"
grid-template-areas: "avatar name followed-by follow"
"avatar note note note";
grid-template-columns: min-content auto 1fr min-content;
grid-column-gap: 10px;
@ -68,19 +75,19 @@
}
}
.account-profile-following, .account-profile-avatar, .account-profile-follow,
.account-profile-followed-by, .account-profile-avatar, .account-profile-follow,
.account-profile-name, .account-profile-username, .account-profile-note {
z-index: 10;
}
.account-profile-following {
grid-area: following;
.account-profile-followed-by {
grid-area: followed-by;
align-self: center;
text-transform: uppercase;
color: var(--deemphasized-text-color);
font-size: 0.8em;
}
.account-profile-following span {
.account-profile-followed-by span {
background: rgba(30, 30, 30, 0.2);
border-radius: 4px;
padding: 3px 5px;
@ -99,11 +106,6 @@
grid-area: follow;
align-self: center;
}
.account-profile-follow svg {
width: 32px;
height: 32px;
fill: var(--svg-fill);
}
.account-profile-name {
grid-area: name;
font-size: 1.5em;
@ -121,9 +123,14 @@
}
</style>
<script>
import IconButton from './IconButton.html'
export default {
computed: {
headerIsMissing: (profile) => profile.header.endsWith('missing.png')
},
components: {
IconButton
}
}
</script>

View File

@ -0,0 +1,56 @@
{{#if pressable}}
<button type="button"
aria-label="{{label}}"
aria-pressed="{{!!pressed}}"
class="icon-button {{pressed ? 'pressed' : ''}} {{big ? 'big-icon' : ''}}">
<svg>
<use xlink:href="{{href}}" />
</svg>
</button>
{{else}}
<button type="button"
aria-label="{{label}}"
class="icon-button {{big ? 'big-icon' : ''}}">
<svg>
<use xlink:href="{{href}}" />
</svg>
</button>
{{/if}}
<style>
button.icon-button {
padding: 6px 10px;
background: none;
border: none;
}
button.icon-button svg {
width: 24px;
height: 24px;
fill: var(--action-button-fill-color);
}
button.icon-button.big-icon svg {
width: 32px;
height: 32px;
}
button.icon-button:hover svg {
fill: var(--action-button-fill-color-hover);
}
button.icon-button:active svg {
fill: var(--action-button-fill-color-active);
}
button.icon-button.pressed svg {
fill: var(--action-button-fill-color-pressed)
}
button.icon-button.pressed:hover svg {
fill: var(--action-button-fill-color-pressed-hover);
}
button.icon-button.pressed:active svg {
fill: var(--action-button-fill-color-pressed-active);
}
</style>

View File

@ -1,30 +1,24 @@
<div class="status-toolbar">
<button aria-label="Reply" type="button">
<svg>
<use xlink:href="#fa-reply" />
</svg>
</button>
<button aria-label="Boost" aria-pressed="{{status.reblogged}}" class="{{status.reblogged ? 'selected' : ''}}" type="button">
<svg>
{{#if status.visibility === 'private'}}
<use xlink:href="#fa-lock" />
{{elseif status.visibility === 'direct'}}
<use xlink:href="#fa-envelope" />
{{else}}
<use xlink:href="#fa-retweet" />
{{/if}}
</svg>
</button>
<button aria-label="Favorite" aria-pressed="{{status.favourited}}" class="{{status.favourited ? 'selected' : ''}}" type="button">
<svg>
<use xlink:href="#fa-star" />
</svg>
</button>
<button aria-label="Show more actions" type="button">
<svg>
<use xlink:href="#fa-ellipsis-h" />
</svg>
</button>
<IconButton
label="Reply"
href="#fa-reply"
/>
<IconButton
label="Boost"
pressable="true"
pressed="{{status.reblogged}}"
href="{{status.visibility === 'private' ? '#fa-lock' : status.visibility === 'direct' ? '#fa-envelope' : '#fa-retweet'}}"
/>
<IconButton
label="Favorite"
pressable="true"
pressed="{{status.favourited}}"
href="#fa-star"
/>
<IconButton
label="Show more actions"
href="#fa-ellipsis-h"
/>
</div>
<style>
.status-toolbar {
@ -32,41 +26,14 @@
display: flex;
justify-content: space-between;
}
.status-toolbar button {
padding: 6px 10px;
background: none;
border: none;
}
.status-toolbar button svg {
width: 24px;
height: 24px;
fill: var(--action-button-fill-color);
}
.status-toolbar button:hover svg {
fill: var(--action-button-fill-color-hover);
}
.status-toolbar button:active svg {
fill: var(--action-button-fill-color-active);
}
.status-toolbar button.selected svg {
fill: var(--action-button-fill-color-pressed)
}
.status-toolbar button.selected:hover svg {
fill: var(--action-button-fill-color-pressed-hover);
}
.status-toolbar button.selected:active svg {
fill: var(--action-button-fill-color-pressed-active);
}
</style>
<script>
import IconButton from '../IconButton.html'
export default {
components: {
IconButton
}
}
</script>

View File

@ -1,10 +1,10 @@
<div class="lazy-timeline">
{{#if !$initialized}}
<div transition:fade>
<!-- <div transition:fade> -->
<div class="loading-page">
<LoadingSpinner />
</div>
</div>
<!-- </div> -->
{{/if}}
{{#await promise}}
{{then constructor}}
@ -33,7 +33,8 @@
<script>
import { importTimeline } from '../../_utils/asyncModules'
import LoadingSpinner from '../LoadingSpinner.html'
import { fade } from 'svelte-transitions'
// TODO: the transition seems to occasionally cause an error in Svelte, transition_run is undefined
//import { fade } from 'svelte-transitions'
import { store } from '../../_utils/store'
export default {
@ -49,9 +50,9 @@
}),
components: {
LoadingSpinner
},
}/*,
transitions: {
fade
}
}*/
}
</script>

View File

@ -0,0 +1,61 @@
import QuickLRU from 'quick-lru'
export const statusesCache = {
maxSize: 100,
caches: {}
}
export const accountsCache = {
maxSize: 50,
caches: {}
}
export const relationshipsCache = {
maxSize: 20,
caches: {}
}
export const metaCache = {
maxSize: 20,
caches: {}
}
if (process.browser && process.env.NODE_ENV !== 'production') {
window.cacheStats = {
statuses: statusesCache,
accounts: accountsCache,
relationships: relationshipsCache,
meta: metaCache
}
}
function getOrCreateInstanceCache(cache, instanceName) {
let cached = cache.caches[instanceName]
if (!cached) {
cached = cache.caches[instanceName] = new QuickLRU({maxSize: cache.maxSize})
}
return cached
}
export function clearCache(cache, instanceName) {
delete cache.caches[instanceName]
}
export function setInCache(cache, instanceName, key, value) {
let instanceCache = getOrCreateInstanceCache(cache, instanceName)
return instanceCache.set(key, value)
}
export function getInCache(cache, instanceName, key) {
let instanceCache = getOrCreateInstanceCache(cache, instanceName)
return instanceCache.get(key)
}
export function hasInCache(cache, instanceName, key) {
let instanceCache = getOrCreateInstanceCache(cache, instanceName)
let res = instanceCache.has(key)
if (process.env.NODE_ENV !== 'production') {
if (res) {
cache.hits = (cache.hits || 0) + 1
} else {
cache.misses = (cache.misses || 0) + 1
}
}
return res
}

View File

@ -1,4 +1,5 @@
export const STATUSES_STORE = 'statuses'
export const TIMELINE_STORE = 'timelines'
export const META_STORE = 'meta'
export const ACCOUNTS_STORE= 'accounts'
export const ACCOUNTS_STORE = 'accounts'
export const RELATIONSHIPS_STORE = 'relationships'

View File

@ -10,69 +10,44 @@ import {
import {
META_STORE,
TIMELINE_STORE,
STATUSES_STORE, ACCOUNTS_STORE
STATUSES_STORE,
ACCOUNTS_STORE,
RELATIONSHIPS_STORE
} from './constants'
import QuickLRU from 'quick-lru'
import {
statusesCache,
relationshipsCache,
accountsCache,
metaCache,
clearCache,
getInCache,
hasInCache,
setInCache
} from './cache'
const statusesCache = {
maxSize: 100,
caches: {}
}
const accountsCache = {
maxSize: 50,
caches: {}
}
const metaCache = {
maxSize: 20,
caches: {}
}
//
// helpers
//
if (process.browser && process.env.NODE_ENV !== 'production') {
window.cacheStats = {
statuses: {
cache: statusesCache,
hits: 0,
misses: 0
},
accounts: {
cache: accountsCache,
hits: 0,
misses: 0
},
meta: {
cache: accountsCache,
hits: 0,
misses: 0
}
async function getGenericEntityWithId(store, cache, instanceName, id) {
if (hasInCache(cache, instanceName, id)) {
return getInCache(cache, instanceName, id)
}
const db = await getDatabase(instanceName)
let result = await dbPromise(db, store, 'readonly', (store, callback) => {
store.get(id).onsuccess = (e) => callback(e.target.result)
})
setInCache(cache, instanceName, id, result)
return result
}
function clearCache(cache, instanceName) {
delete cache.caches[instanceName]
}
function getOrCreateInstanceCache(cache, instanceName) {
let cached = cache.caches[instanceName]
if (!cached) {
cached = cache.caches[instanceName] = new QuickLRU({maxSize: cache.maxSize})
}
return cached
}
function setInCache(cache, instanceName, key, value) {
let instanceCache = getOrCreateInstanceCache(cache, instanceName)
return instanceCache.set(key, value)
}
function getInCache(cache, instanceName, key) {
let instanceCache = getOrCreateInstanceCache(cache, instanceName)
return instanceCache.get(key)
}
function hasInCache(cache, instanceName, key) {
let instanceCache = getOrCreateInstanceCache(cache, instanceName)
return instanceCache.has(key)
async function setGenericEntityWithId(store, cache, instanceName, entity) {
setInCache(cache, instanceName, entity.id, entity)
const db = await getDatabase(instanceName)
return await dbPromise(db, store, 'readwrite', (store) => {
store.put(entity)
})
}
//
@ -129,23 +104,7 @@ export async function insertStatuses(instanceName, timeline, statuses) {
}
export async function getStatus(instanceName, statusId) {
if (hasInCache(statusesCache, instanceName, statusId)) {
if (process.browser && process.env.NODE_ENV !== 'production') {
window.cacheStats.statuses.hits++
}
return getInCache(statusesCache, instanceName, statusId)
}
const db = await getDatabase(instanceName)
let result = await dbPromise(db, STATUSES_STORE, 'readonly', (store, callback) => {
store.get(statusId).onsuccess = (e) => {
callback(e.target.result && e.target.result)
}
})
setInCache(statusesCache, instanceName, statusId, result)
if (process.browser && process.env.NODE_ENV !== 'production') {
window.cacheStats.statuses.misses++
}
return result
return await getGenericEntityWithId(STATUSES_STORE, statusesCache, instanceName, statusId)
}
//
@ -154,9 +113,6 @@ export async function getStatus(instanceName, statusId) {
async function getMetaProperty(instanceName, key) {
if (hasInCache(metaCache, instanceName, key)) {
if (process.browser && process.env.NODE_ENV !== 'production') {
window.cacheStats.meta.hits++
}
return getInCache(metaCache, instanceName, key)
}
const db = await getDatabase(instanceName)
@ -166,9 +122,6 @@ async function getMetaProperty(instanceName, key) {
}
})
setInCache(metaCache, instanceName, key, result)
if (process.browser && process.env.NODE_ENV !== 'production') {
window.cacheStats.meta.misses++
}
return result
}
@ -200,34 +153,23 @@ export async function setInstanceInfo(instanceName, value) {
}
//
// accounts
// accounts/relationships
//
export async function getAccount(instanceName, accountId) {
if (hasInCache(accountsCache, instanceName, accountId)) {
if (process.browser && process.env.NODE_ENV !== 'production') {
window.cacheStats.accounts.hits++
}
return getInCache(accountsCache, instanceName, accountId)
}
const db = await getDatabase(instanceName)
let result = await dbPromise(db, ACCOUNTS_STORE, 'readonly', (store, callback) => {
store.get(accountId).onsuccess = (e) => {
callback(e.target.result && e.target.result)
}
})
if (process.browser && process.env.NODE_ENV !== 'production') {
window.cacheStats.accounts.misses++
}
return result
return await getGenericEntityWithId(ACCOUNTS_STORE, accountsCache, instanceName, accountId)
}
export async function setAccount(instanceName, account) {
setInCache(accountsCache, instanceName, account.id, account)
const db = await getDatabase(instanceName)
return await dbPromise(db, ACCOUNTS_STORE, 'readwrite', (store) => {
store.put(account)
})
return await setGenericEntityWithId(ACCOUNTS_STORE, accountsCache, instanceName, account)
}
export async function getRelationship(instanceName, accountId) {
return await getGenericEntityWithId(RELATIONSHIPS_STORE, relationshipsCache, instanceName, accountId)
}
export async function setRelationship(instanceName, relationship) {
return await setGenericEntityWithId(RELATIONSHIPS_STORE, relationshipsCache, instanceName, relationship)
}
//

View File

@ -1,11 +1,14 @@
const openReqs = {}
const databaseCache = {}
const DB_VERSION = 2
import {
META_STORE,
TIMELINE_STORE,
STATUSES_STORE,
ACCOUNTS_STORE
ACCOUNTS_STORE,
RELATIONSHIPS_STORE
} from './constants'
export function getDatabase(instanceName) {
@ -17,19 +20,24 @@ export function getDatabase(instanceName) {
}
databaseCache[instanceName] = new Promise((resolve, reject) => {
let req = indexedDB.open(instanceName, 1)
let req = indexedDB.open(instanceName, DB_VERSION)
openReqs[instanceName] = req
req.onerror = reject
req.onblocked = () => {
console.log('idb blocked')
}
req.onupgradeneeded = () => {
req.onupgradeneeded = (e) => {
let db = req.result;
db.createObjectStore(META_STORE, {keyPath: 'key'})
db.createObjectStore(STATUSES_STORE, {keyPath: 'id'})
db.createObjectStore(ACCOUNTS_STORE, {keyPath: 'id'})
let timelineStore = db.createObjectStore(TIMELINE_STORE, {keyPath: 'id'})
timelineStore.createIndex('statusId', 'statusId')
if (e.oldVersion < 1) {
db.createObjectStore(META_STORE, {keyPath: 'key'})
db.createObjectStore(STATUSES_STORE, {keyPath: 'id'})
db.createObjectStore(ACCOUNTS_STORE, {keyPath: 'id'})
let timelineStore = db.createObjectStore(TIMELINE_STORE, {keyPath: 'id'})
timelineStore.createIndex('statusId', 'statusId')
}
if (e.oldVersion < 2) {
db.createObjectStore(RELATIONSHIPS_STORE, {keyPath: 'id'})
}
}
req.onsuccess = () => resolve(req.result)
})

View File

@ -1,4 +1,4 @@
import { get } from '../ajax'
import { get, paramsString } from '../ajax'
import { basename } from './utils'
export function getVerifyCredentials(instanceName, accessToken) {
@ -13,4 +13,13 @@ export function getAccount(instanceName, accessToken, accountId) {
return get(url, {
'Authorization': `Bearer ${accessToken}`
})
}
export async function getRelationship(instanceName, accessToken, accountId) {
let url = `${basename(instanceName)}/api/v1/accounts/relationships`
url += '?' + paramsString({id: accountId})
let res = await get(url, {
'Authorization': `Bearer ${accessToken}`
})
return res[0]
}

View File

@ -1,4 +1,5 @@
import { Store } from 'svelte/store.js'
import { storeObservers } from './storeObservers'
const LOCAL_STORAGE_KEYS = new Set([
"currentInstance",
@ -112,6 +113,12 @@ store.compute(
}
)
store.compute(
'currentVerifyCredentials',
['currentInstance', 'verifyCredentials'],
(currentInstance, verifyCredentials) => verifyCredentials && verifyCredentials[currentInstance]
)
store.compute('currentTimelineData', ['currentInstance', 'currentTimeline', 'timelines'],
(currentInstance, currentTimeline, timelines) => {
return ((timelines && timelines[currentInstance]) || {})[currentTimeline] || {}
@ -122,6 +129,8 @@ store.compute('runningUpdate', ['currentTimelineData'], (currentTimelineData) =>
store.compute('initialized', ['currentTimelineData'], (currentTimelineData) => currentTimelineData.initialized)
store.compute('lastStatusId', ['statusIds'], (statusIds) => statusIds.length && statusIds[statusIds.length - 1])
storeObservers(store)
if (process.browser && process.env.NODE_ENV !== 'production') {
window.store = store // for debugging
}

View File

@ -0,0 +1,7 @@
import { updateVerifyCredentialsForInstance } from '../settings/instances/_actions/[instanceName]'
export function storeObservers(store) {
store.observe('currentInstance', (currentInstance) => {
updateVerifyCredentialsForInstance(currentInstance)
})
}

View File

@ -12,7 +12,10 @@
{{#if $isUserLoggedIn}}
<DynamicPageBanner title="{{profileName}}" />
{{#if $currentAccountProfile}}
<AccountProfile profile="{{$currentAccountProfile}}" />
<AccountProfile profile="{{$currentAccountProfile}}"
relationship="{{$currentAccountRelationship}}"
verifyCredentials="{{$currentVerifyCredentials}}"
/>
{{/if}}
<LazyTimeline timeline='account/{{params.accountId}}' />
{{else}}
@ -32,13 +35,16 @@
import { store } from '../_utils/store.js'
import HiddenFromSSR from '../_components/HiddenFromSSR'
import DynamicPageBanner from '../_components/DynamicPageBanner.html'
import { showAccountProfile } from './_actions/[accountId]'
import { updateProfileAndRelationship } from './_actions/[accountId]'
import AccountProfile from '../_components/AccountProfile.html'
import { updateVerifyCredentialsForInstance } from '../settings/instances/_actions/[instanceName]'
export default {
oncreate() {
let accountId = this.get('params').accountId
showAccountProfile(accountId)
let instanceName = this.store.get('currentInstance')
updateProfileAndRelationship(accountId)
updateVerifyCredentialsForInstance(instanceName)
},
store: () => store,
computed: {

View File

@ -1,24 +1,54 @@
import { getAccount } from '../../_utils/mastodon/user'
import { getAccount, getRelationship } from '../../_utils/mastodon/user'
import { database } from '../../_utils/database/database'
import { store } from '../../_utils/store'
export async function showAccountProfile(accountId) {
store.set({currentAccountProfile: null})
let instanceName = store.get('currentInstance')
let accessToken = store.get('accessToken')
async function updateAccount(accountId, instanceName, accessToken) {
let localPromise = database.getAccount(instanceName, accountId)
let remotePromise = getAccount(instanceName, accessToken, accountId).then(account => {
database.setAccount(instanceName, account)
return account
})
let localAccount = await localPromise
store.set({currentAccountProfile: localAccount})
try {
let remoteAccount = await remotePromise
store.set({currentAccountProfile: remoteAccount})
store.set({currentAccountProfile: (await localPromise)})
} catch (e) {
console.error("couldn't fetch profile", e)
console.error(e)
}
try {
store.set({currentAccountProfile: (await remotePromise)})
} catch (e) {
console.error(e)
}
}
async function updateRelationship(accountId, instanceName, accessToken) {
let localPromise = database.getRelationship(instanceName, accountId)
let remotePromise = getRelationship(instanceName, accessToken, accountId).then(relationship => {
database.setRelationship(instanceName, relationship)
return relationship
})
try {
store.set({currentAccountRelationship: (await localPromise)})
} catch (e) {
console.error(e)
}
try {
store.set({currentAccountRelationship: (await remotePromise)})
} catch (e) {
console.error(e)
}
}
export async function updateProfileAndRelationship(accountId) {
store.set({
currentAccountProfile: null,
currentAccountRelationship: null
})
let instanceName = store.get('currentInstance')
let accessToken = store.get('accessToken')
await Promise.all([
updateAccount(accountId, instanceName, accessToken),
updateRelationship(accountId, instanceName, accessToken)
])
}

View File

@ -63,5 +63,5 @@
--deemphasized-text-color: #666;
--focus-outline: $focus-outline;
--status-direct-background: darken($body-bg-color, 5%)
--status-direct-background: darken($body-bg-color, 5%);
}