Showing preview only (359K chars total). Download the full file or copy to clipboard to get everything.
Repository: NanaMorse/Cuckoo.Plus
Branch: develop
Commit: 20f4bc6832ac
Files: 95
Total size: 330.8 KB
Directory structure:
gitextract_m6ecmic0/
├── .editorconfig
├── .gitignore
├── .travis.yml
├── LICENSE
├── README.md
├── deploy.sh
├── light-sail-cuckoo-plus.pem.enc
├── package.json
├── public/
│ ├── index.html
│ ├── manifest.json
│ └── sw.js
├── server.js
├── src/
│ ├── App.vue
│ ├── api/
│ │ ├── accounts.ts
│ │ ├── apps.ts
│ │ ├── index.ts
│ │ ├── instances.ts
│ │ ├── lists.ts
│ │ ├── media.ts
│ │ ├── notifications.ts
│ │ ├── oauth.ts
│ │ ├── search.ts
│ │ ├── statuses.ts
│ │ ├── streaming.ts
│ │ └── timelines.ts
│ ├── components/
│ │ ├── Drawer/
│ │ │ ├── PeopleResultCard.vue
│ │ │ ├── Search.vue
│ │ │ └── index.vue
│ │ ├── Header.vue
│ │ ├── Input.vue
│ │ ├── Notifications/
│ │ │ ├── Card.vue
│ │ │ └── index.vue
│ │ ├── PostStatusDialog.vue
│ │ ├── StatusCard/
│ │ │ ├── CardHeader.vue
│ │ │ ├── FullActionBar.vue
│ │ │ ├── FullReplyListItem.vue
│ │ │ ├── LinkPreviewPanel.vue
│ │ │ ├── MediaPanel.vue
│ │ │ ├── PlaceHolderMediaItem.vue
│ │ │ ├── SimpleActionBar.vue
│ │ │ └── index.vue
│ │ ├── ThemeEditPanel.vue
│ │ └── VisibilitySelectPopOver.vue
│ ├── constant/
│ │ ├── i18n.ts
│ │ ├── index.ts
│ │ └── routers.ts
│ ├── directives.ts
│ ├── formatter.spec.ts
│ ├── formatter.ts
│ ├── i18n/
│ │ ├── compare
│ │ ├── de.ts
│ │ ├── en.ts
│ │ ├── index.ts
│ │ ├── ja.ts
│ │ ├── zh-cn.ts
│ │ ├── zh-hk.ts
│ │ └── zh-tw.ts
│ ├── index.ts
│ ├── interface/
│ │ ├── definition/
│ │ │ ├── vue-extend.d.ts
│ │ │ └── vue-shims.d.ts
│ │ ├── entities.ts
│ │ ├── index.ts
│ │ └── store.ts
│ ├── pages/
│ │ ├── Accounts/
│ │ │ ├── AccountHeader.vue
│ │ │ └── index.vue
│ │ ├── OAuth.vue
│ │ ├── Settings.vue
│ │ ├── Statuses.vue
│ │ └── Timelines/
│ │ ├── NewStatusNoticeButton.vue
│ │ ├── PostStatusStampCard.vue
│ │ └── index.vue
│ ├── router/
│ │ └── index.ts
│ ├── store/
│ │ ├── actions/
│ │ │ ├── accounts.ts
│ │ │ ├── appstatus.ts
│ │ │ ├── index.ts
│ │ │ ├── notifications.ts
│ │ │ ├── relationships.ts
│ │ │ ├── statuses.ts
│ │ │ └── timelines.ts
│ │ ├── getters/
│ │ │ └── index.ts
│ │ ├── index.ts
│ │ └── mutations/
│ │ ├── appstatus.ts
│ │ ├── index.ts
│ │ ├── notifications.ts
│ │ └── timelines.ts
│ ├── themes/
│ │ ├── basecolor.ts
│ │ ├── index.ts
│ │ ├── presets/
│ │ │ ├── cuckoohub.ts
│ │ │ ├── dark.ts
│ │ │ ├── googleplus.ts
│ │ │ └── greenlight.ts
│ │ └── stylepattern.ts
│ └── util.ts
├── tsconfig.json
└── webpack.config.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .editorconfig
================================================
root = true
[*]
end_of_line = lf
[*.{js,ts,vue,json}]
indent_size = 2
================================================
FILE: .gitignore
================================================
.idea
package-lock.json
node_modules
public/dist
.DS_store
================================================
FILE: .travis.yml
================================================
language: node_js
node_js:
- 10.15.3
addons:
ssh_known_hosts: 52.76.67.104
before_deploy:
- openssl aes-256-cbc -K $encrypted_7892896c2e12_key -iv $encrypted_7892896c2e12_iv
-in light-sail-cuckoo-plus.pem.enc -out light-sail-cuckoo-plus.pem -d
- eval "$(ssh-agent -s)"
- chmod 600 light-sail-cuckoo-plus.pem
- ssh-add light-sail-cuckoo-plus.pem
deploy:
provider: script
script: bash ./deploy.sh
skip_cleanup: true
branches:
only:
- master
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2018 Morse_Guo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# Cuckoo.Plus [](https://travis-ci.com/NanaMorse/Cuckoo.Plus)
alpha view link: [Cuckoo.Social](http://www.cuckoo.social)
Start project.
```
npm i
npm run dev
```
then open [localhost:3000](http://localhost:3000) in the browser.
Build project.
```
npm run build
```
the files located in public folder are all you need
项目完成状态可以在 [Cuckoo.Social](http://www.cuckoo.social) 中自行体验,没有的就是没做,点不动的一般也是没做。
## Licence
MIT.
================================================
FILE: deploy.sh
================================================
npm run build
scp -i "light-sail-cuckoo-plus.pem" -r public ubuntu@52.76.67.104:projects/Cuckoo.Plus/
================================================
FILE: package.json
================================================
{
"name": "cuckoo.plus",
"version": "0.3.27",
"description": "A third-party client for mastodon",
"scripts": {
"test": "mocha -r ts-node/register src/**/*.spec.ts",
"dev": "webpack-dev-server",
"build": "webpack --env=production",
"start": "node server.js"
},
"dependencies": {
"autosize": "^4.0.2",
"crypto-js": "^3.1.9-1",
"express": "^4.16.4",
"file-saver": "^2.0.1",
"jquery": "^3.4.1",
"less": "^3.9.0",
"masonry-layout": "^4.2.2",
"moment": "^2.22.2",
"muse-ui": "^3.0.1",
"muse-ui-loading": "^0.2.0",
"muse-ui-message": "^0.2.1",
"muse-ui-progress": "^0.1.0",
"muse-ui-toast": "^0.3.0",
"resize-observer-polyfill": "^1.5.1",
"sha1": "^1.1.1",
"textarea-caret": "^3.1.0",
"underscore": "^1.9.1",
"vue": "^2.5.13",
"vue-color": "^2.7.0",
"vue-i18n": "^8.1.0",
"vue-resource": "^1.5.1",
"vue-router": "^3.0.1",
"vuex": "^3.0.1"
},
"devDependencies": {
"@types/chai": "^4.1.6",
"@types/crypto-js": "^3.1.43",
"@types/jquery": "^3.3.31",
"@types/less": "^3.0.0",
"@types/mocha": "^5.2.5",
"@types/node": "^10.11.7",
"@types/sha1": "^1.1.1",
"@types/underscore": "^1.8.9",
"@types/vue-color": "^2.4.2",
"@types/vue-resource": "^0.9.34",
"babel-core": "^6.26.0",
"babel-loader": "^7.1.2",
"babel-minify-webpack-plugin": "^0.3.1",
"babel-plugin-transform-class-properties": "^6.24.1",
"babel-preset-es2015": "^6.24.1",
"chai": "^4.2.0",
"css-loader": "^0.28.9",
"husky": "^1.1.2",
"less": "^3.8.1",
"less-loader": "^4.1.0",
"mocha": "^5.2.0",
"node-sass": "^4.9.3",
"raw-loader": "^0.5.1",
"sass-loader": "^7.1.0",
"style-loader": "^0.23.1",
"ts-loader": "^3.5.0",
"ts-node": "^7.0.1",
"typescript": "^2.7.1",
"vue-loader": "^14.2.3",
"vue-property-decorator": "^7.2.0",
"vue-template-compiler": "^2.5.13",
"vuex-class": "^0.3.1",
"webpack": "^3.11.0",
"webpack-bundle-analyzer": "^3.0.3",
"webpack-dev-server": "^2.11.3",
"yargs": "^12.0.2"
},
"babel": {
"presets": [
"es2015"
]
},
"husky": {
"hooks": {
"pre-push": "npm run test"
}
},
"repository": {
"type": "git",
"url": "git+https://github.com/NanaMorse/Cuckoo.Plus.git"
},
"author": "Nana.Morse, KTachibanaM, roytam1, cutls, hakaba-hitoyo",
"thanks": "roytam1, fhoshino",
"license": "MIT",
"bugs": {
"url": "https://github.com/NanaMorse/Cuckoo.Plus/issues"
},
"homepage": "https://github.com/NanaMorse/Cuckoo.Plus#readme"
}
================================================
FILE: public/index.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-135462687-1"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'UA-135462687-1');
</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<!-- ignore user setting for uc browser -->
<meta name="layoutmode" content="standard"/>
<meta name="renderer" content="webkit">
<meta name="force-rendering" content="webkit">
<meta name="theme-color" content="#db4437">
<link rel="manifest" href="manifest.json">
<link rel="icon" type="image/png" href="favicon/google_plus/48x48.png" sizes="48x48">
<link rel="icon" type="image/png" href="favicon/google_plus/72x72.png" sizes="72x72">
<link rel="icon" type="image/png" href="favicon/google_plus/96x96.png" sizes="96x96">
<link rel="icon" type="image/png" href="favicon/google_plus/144x144.png" sizes="144x144">
<link rel="icon" type="image/png" href="favicon/google_plus/192x192.png" sizes="192x192">
<link rel="apple-touch-icon" href="favicon/apple-touch-icon.jpg">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<link href='https://fonts.loli.net/css?family=Open+Sans' rel='stylesheet'>
<link href='https://fonts.loli.net/icon?family=Material+Icons' rel='stylesheet'>
<script src="https://cdnjs.loli.net/ajax/libs/moment.js/2.22.2/moment.min.js"></script>
<script src="https://cdnjs.loli.net/ajax/libs/moment.js/2.22.2/locale/zh-cn.js"></script>
<script src="https://cdnjs.loli.net/ajax/libs/moment.js/2.22.2/locale/zh-hk.js"></script>
<script src="https://cdnjs.loli.net/ajax/libs/moment.js/2.22.2/locale/zh-tw.js"></script>
<script src="https://cdnjs.loli.net/ajax/libs/moment.js/2.22.2/locale/ja.js"></script>
<script src="https://cdnjs.loli.net/ajax/libs/moment.js/2.22.2/locale/de.js"></script>
<script src="https://cdnjs.loli.net/ajax/libs/underscore.js/1.9.1/underscore-min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/muse-ui/dist/muse-ui.css">
<title>Cuckoo+</title>
</head>
<body>
<div id="app"></div>
<script src="dist/bundle.js"></script>
</body>
</html>
================================================
FILE: public/manifest.json
================================================
{
"name" : "Cuckoo Social",
"short_name" : "Cuckoo+",
"start_url" : "/",
"display" : "standalone",
"icons": [
{
"src" : "favicon/google_plus/48x48.png",
"sizes" : "48x48",
"type" : "image/png"
},
{
"src" : "favicon/google_plus/72x72.png",
"sizes" : "72x72",
"type" : "image/png"
},
{
"src" : "favicon/google_plus/96x96.png",
"sizes" : "96x96",
"type" : "image/png"
},
{
"src" : "favicon/google_plus/144x144.png",
"sizes" : "144x144",
"type" : "image/png"
},
{
"src" : "favicon/google_plus/192x192.png",
"sizes" : "192x192",
"type" : "image/png"
}
]
}
================================================
FILE: public/sw.js
================================================
const version = '0.3.27'
const CACHE = version + ':CP'
const cacheFilePaths = [
'/',
'/manifest.json',
'/dist/bundle.js',
'https://fonts.loli.net/css?family=Open+Sans',
'https://fonts.loli.net/icon?family=Material+Icons',
'https://cdnjs.loli.net/ajax/libs/moment.js/2.22.2/moment.min.js',
'https://cdnjs.loli.net/ajax/libs/moment.js/2.22.2/locale/zh-cn.js',
'https://cdnjs.loli.net/ajax/libs/moment.js/2.22.2/locale/zh-hk.js',
'https://cdnjs.loli.net/ajax/libs/moment.js/2.22.2/locale/zh-tw.js',
'https://cdnjs.loli.net/ajax/libs/moment.js/2.22.2/locale/ja.js',
'https://cdnjs.loli.net/ajax/libs/underscore.js/1.9.1/underscore-min.js',
'https://unpkg.com/muse-ui/dist/muse-ui.css',
'https://gstatic.loli.net/s/materialicons/v46/flUhRq6tzZclQEJ-Vdg-IuiaDsNc.woff2',
]
const swContext = this
class SW {
constructor () {
this.initInstallEventListener()
this.initActivateEventListener()
this.initFetchEventListener()
}
initInstallEventListener () {
swContext.addEventListener('install', event => {
event.waitUntil(this.installFiles().then(() => swContext.skipWaiting()))
})
}
installFiles () {
return caches.open(CACHE).then(cache => {
return cache.addAll(cacheFilePaths)
})
}
initActivateEventListener () {
swContext.addEventListener('activate', event => {
// delete old caches
event.waitUntil(this.clearOldCaches().then(() => swContext.clients.claim()))
});
}
clearOldCaches () {
return caches.keys().then(keylist => {
return Promise.all(keylist.filter(key => key !== CACHE).map(key => caches.delete(key)))
})
}
initFetchEventListener () {
swContext.addEventListener('fetch', event => {
// abandon non-GET requests
if (event.request.method !== 'GET') return
const request = event.request
const url = request.url
const isRequestImage = event.request.destination === 'image'
if (!this.isCacheFilePath(url) && !isRequestImage) return
return event.respondWith(caches.open(CACHE).then(cache => {
return cache.match(request).then(response => {
if (response) return response
return fetch(request).then(response => {
if (response.ok) cache.put(request, response.clone())
return response
}).catch()
})
}))
})
}
isCacheFilePath (url) {
return cacheFilePaths.some(filePath => url.endsWith(filePath))
}
}
new SW()
================================================
FILE: server.js
================================================
const express = require('express')
const http = require('http')
const https = require('https')
const fs = require('fs')
const httpApp = express()
const httpsApp = express()
const httpPort = 80
const httpsPort = 443
httpApp.all("*", (req, res) => {
let host = req.headers.host
host = host.replace(/:\d+$/, '')
res.redirect(301, `https://${host}${req.path}`)
})
http.createServer(httpApp).listen(httpPort)
httpsApp.use(express.static('public'))
https.createServer({
key: fs.readFileSync('../ssl/private.key'),
cert: fs.readFileSync('../ssl/certificate.pem')
}, httpsApp).listen(httpsPort)
================================================
FILE: src/App.vue
================================================
<template>
<div id="app">
<cuckoo-plus-header v-if="!$route.meta.hideHeader"/>
<cuckoo-plus-drawer v-if="!$route.meta.hideDrawer && isOAuthUser"/>
<mu-container :fluid="true" class="app-content" :style="appContentStyle">
<keep-alive>
<router-view v-if="$route.meta.keepAlive" />
</keep-alive>
<router-view v-if="!$route.meta.keepAlive" />
</mu-container>
<theme-edit-panel v-if="appStatus.isEditingThemeMode"/>
</div>
</template>
<script lang="ts">
import { Vue, Component, Watch } from 'vue-property-decorator'
import { Mutation, State, Getter } from 'vuex-class'
import * as _ from 'underscore'
import { UiWidthCheckConstants, TimeLineTypes, TITLE } from '@/constant'
import Header from '@/components/Header.vue'
import Drawer from '@/components/Drawer'
import ThemeEditPanel from '@/components/ThemeEditPanel'
@Component({
components: {
'cuckoo-plus-header': Header,
'cuckoo-plus-drawer': Drawer,
'theme-edit-panel': ThemeEditPanel
}
})
class App extends Vue {
$route
@State('appStatus') appStatus
@State('timelines') timelines
@State('contextMap') contextMap
@State('statusMap') statusMap
@State('cardMap') cardMap
@Mutation('updateDocumentWidth') updateDocumentWidth
@Getter('isOAuthUser') isOAuthUser
@Getter('isMobileMode') isMobileMode
mounted () {
window.addEventListener('resize', _.debounce(() => this.updateDocumentWidth(), 200))
this.listenToWindowUnload()
}
@Watch('appStatus.unreadNotificationCount')
onUnreadNotificationCountChanged () {
document.querySelector('title').innerText = this.appStatus.unreadNotificationCount > 0 ?
`(${this.appStatus.unreadNotificationCount}) ${TITLE}` : `${TITLE}`
}
get appContentStyle () {
if (this.appStatus.isDrawerOpened &&
!this.$route.meta.hideDrawer &&
this.isOAuthUser && !this.isMobileMode) {
return {
paddingLeft: `${UiWidthCheckConstants.DRAWER_DESKTOP_WIDTH}px`
}
}
}
listenToWindowUnload () {
window.addEventListener('unload', () => {
// save timelines
localStorage.setItem(TimeLineTypes.HOME, JSON.stringify(this.timelines[TimeLineTypes.HOME]))
// save contextMap
localStorage.setItem('contextMap', JSON.stringify(this.contextMap))
// save statusMap
localStorage.setItem('statusMap', JSON.stringify(this.statusMap))
localStorage.setItem('cardMap', JSON.stringify(this.cardMap))
})
}
}
export default App
</script>
<style lang="less" scoped>
.app-content {
padding: 56px 0 0 0;
-webkit-transition: padding-left .45s cubic-bezier(.23,1,.32,1);
-moz-transition: padding-left .45s cubic-bezier(.23,1,.32,1);
-ms-transition: padding-left .45s cubic-bezier(.23,1,.32,1);
-o-transition: padding-left .45s cubic-bezier(.23,1,.32,1);
transition: padding-left .45s cubic-bezier(.23,1,.32,1);
}
@media (min-width: 600px) {
.app-content {
padding: 64px 0 0 0;
}
}
</style>
<style lang="less">
body {
height: 100%;
font-family: Roboto,RobotoDraft,Helvetica,Arial,sans-serif;
}
a, .mu-load-more {
-webkit-user-select: auto;
-moz-user-select: auto;
-ms-user-select: auto;
user-select: auto;
}
// header z-index 20141223
// drawer z-index 20141224
.mu-loading-wrap {
z-index: 20141222 !important;
}
.drag-over-layer {
display: flex;
align-items: center;
justify-content: center;
}
.custom-emoji {
width: 20px;
height: 20px;
vertical-align: text-bottom;
}
.netease-music-iframe {
display: block;
width: 100%;
}
.youtube-video-iframe {
display: block;
width: 100%;
}
@keyframes fadein {
from { opacity: 0; }
to { opacity: 1; }
}
</style>
================================================
FILE: src/api/accounts.ts
================================================
import Vue from 'vue'
import { mastodonentities } from '@/interface'
import { patchApiUri } from '@/util'
interface updateAccountFormData {
// The name to display in the user's profile
display_name?: string
// A new biography for the user
note?: string
// An avatar for the user (encoded using multipart/form-data)
avatar?: FormData
// A header image for the user (encoded using multipart/form-data)
header?: FormData
// Manually approve followers?
locked?: boolean
// (2.4 or later) extra source attribute from verify_credentials
source?: {
privacy?: string
sensitive?: boolean
note?: string
fields?: Array<any>
}
}
async function fetchAccountInfoById () {
}
async function fetchCurrentUserAccountInfo (): Promise<{ data: mastodonentities.AuthenticatedAccount }> {
return Vue.http.get(patchApiUri('/api/v1/accounts/verify_credentials')) as any
}
async function updateUserAccountInfo (formData: updateAccountFormData): Promise<{ data: mastodonentities.AuthenticatedAccount }> {
return Vue.http.patch(patchApiUri('/api/v1/accounts/update_credentials'), formData) as any
}
async function fetchRelationships (idList: Array<string>) {
return Vue.http.get(patchApiUri('/api/v1/accounts/relationships'), {
params: {
id: idList
}
}) as any
}
async function followAccountById (id: string) {
return Vue.http.post(patchApiUri(`/api/v1/accounts/${id}/follow`)) as any
}
async function unFollowAccountById (id: string) {
return Vue.http.post(patchApiUri(`/api/v1/accounts/${id}/unfollow`)) as any
}
export {
fetchAccountInfoById,
fetchCurrentUserAccountInfo,
updateUserAccountInfo,
fetchRelationships,
followAccountById,
unFollowAccountById
}
================================================
FILE: src/api/apps.ts
================================================
import Vue from 'vue'
import { patchApiUri } from '@/util'
const clientName = 'Cuckoo.Plus'
const scopes = 'read write follow'
namespace Apps {
export interface registerApplicationFormData {
// Name of your application
client_name?: string
// Where the user should be redirected after authorization
// (for no redirect, use urn:ietf:wg:oauth:2.0:oob)
redirect_uris?: string
// This can be a space-separated list of the following items: "read", "write" and "follow"
scopes?: string
// URL to the homepage of your app
website?: string
}
export interface registerApplicationReturnData {
data: {
client_id: string,
client_secret: string,
id: string,
name: string,
redirect_uri: string,
website: string | null
}
}
}
async function registerApplication (): Promise<Apps.registerApplicationReturnData> {
const formData: Apps.registerApplicationFormData = {
client_name: clientName,
redirect_uris: location.origin,
scopes: scopes
}
return Vue.http.post(patchApiUri('/api/v1/apps'), formData) as any
}
export {
registerApplication
}
================================================
FILE: src/api/index.ts
================================================
import * as apps from './apps'
import * as oauth from './oauth'
import * as accounts from './accounts'
import * as lists from './lists'
import * as timelines from './timelines'
import * as statuses from './statuses'
import * as notifications from './notifications'
import * as media from './media'
import * as instances from './instances'
import * as search from './search'
import streaming from './streaming'
export {
apps,
oauth,
accounts,
lists,
timelines,
statuses,
notifications,
media,
streaming,
instances,
search
}
================================================
FILE: src/api/instances.ts
================================================
import Vue from 'vue'
import { mastodonentities } from '@/interface'
import { patchApiUri } from '@/util'
async function getCustomEmojis (): Promise<{ data: Array<mastodonentities.Emoji> }> {
return Vue.http.get(patchApiUri('/api/v1/custom_emojis')) as any
}
export {
getCustomEmojis
}
================================================
FILE: src/api/lists.ts
================================================
import Vue from 'vue'
import { patchApiUri } from '@/util'
async function receiveLists () {
return Vue.http.get(patchApiUri('/api/v1/instance'))
}
export {
receiveLists
}
================================================
FILE: src/api/media.ts
================================================
import Vue from 'vue'
import { mastodonentities } from '@/interface'
import { patchApiUri } from '@/util'
async function postMediaFile (formData): Promise<{ data: mastodonentities.Attachment }> {
return Vue.http.post(patchApiUri('/api/v1/media'), formData) as any
}
export {
postMediaFile
}
================================================
FILE: src/api/notifications.ts
================================================
import Vue from 'vue'
import { mastodonentities } from '@/interface'
import { patchApiUri } from '@/util'
interface getNotificationsQueryParams {
// Get a list of notifications with ID less than this value
max_id?: string
// Get a list of notifications with ID greater than this value
since_id?: string
// Maximum number of notifications to get (Default 15, Max 30)
limit?: number
// Array of notifications to exclude (Allowed values: "follow", "favourite", "reblog", "mention")
exclude_types?: Array<mastodonentities.NotificationType>
}
async function getNotifications(queryParams: getNotificationsQueryParams): Promise<{ data: Array<mastodonentities.Notification> }> {
queryParams.limit = 30
const config = {
params: queryParams
}
return Vue.http.get(patchApiUri('/api/v1/notifications'), config) as any
}
export {
getNotifications
}
================================================
FILE: src/api/oauth.ts
================================================
import Vue from 'vue'
import store from '@/store'
import { patchApiUri } from '@/util'
import HttpResponse = vuejs.HttpResponse;
interface fetchOAuthTokenReturnData extends HttpResponse {
data: {
access_token: string
}
}
async function fetchOAuthToken (): Promise<fetchOAuthTokenReturnData> {
const OAuthInfo = store.state.OAuthInfo
const formData = {
client_id: OAuthInfo.clientId,
client_secret: OAuthInfo.clientSecret,
redirect_uri: location.origin,
grant_type: "authorization_code",
code: OAuthInfo.code
}
return await Vue.http.post(patchApiUri('/oauth/token'), formData) as any
}
export {
fetchOAuthToken
}
================================================
FILE: src/api/search.ts
================================================
import Vue from 'vue'
import { mastodonentities } from '@/interface'
import { patchApiUri } from '@/util'
let preSearchRequest
/**
* @param q The search query
* @param resolve Whether to resolve non-local accounts (default: don't resolve)
* */
async function getSearchResults (q: string, resolve: boolean = false): Promise<{ data: mastodonentities.SearchResults }> {
return Vue.http.get(patchApiUri('/api/v1/search'), {
params: {
q, resolve
},
before(request) {
abortSearch()
preSearchRequest = request
}
}) as any
}
function abortSearch () {
if (preSearchRequest) preSearchRequest.abort()
}
export {
getSearchResults,
abortSearch
}
================================================
FILE: src/api/statuses.ts
================================================
import Vue from 'vue'
import { mastodonentities } from '@/interface'
import { patchApiUri, generateUniqueKey } from '@/util'
import { VisibilityTypes } from '@/constant'
async function getStatusById (id: string): Promise<{ data: mastodonentities.Status }> {
return Vue.http.get(patchApiUri(`/api/v1/statuses/${id}`)) as any
}
interface postStatusFormData {
// The text of the status
status: string
// local ID of the status you want to reply to
inReplyToId?: string
// Array of media IDs to attach to the status (maximum 4)
mediaIds?: Array<string>
// Set this to mark the media of the status as NSFW
sensitive?: boolean
// Text to be shown as a warning before the actual content
spoilerText?: string
// Either "direct", "private", "unlisted" or "public"
visibility?: string
// ISO 639-2 language code of the toot, to skip automatic detection
language?: string
}
async function postStatus (formData: postStatusFormData): Promise<{ data: mastodonentities.Status }> {
const apiFormData: any = {}
apiFormData.status = formData.status
apiFormData.in_reply_to_id = formData.inReplyToId
apiFormData.media_ids = formData.mediaIds
apiFormData.sensitive = formData.sensitive
apiFormData.spoiler_text = formData.spoilerText
apiFormData.visibility = formData.visibility || VisibilityTypes.PUBLIC
apiFormData.language = formData.language
const config = {
headers: {
'Idempotency-Key': generateUniqueKey()
}
}
return Vue.http.post(patchApiUri(`/api/v1/statuses`), apiFormData, config) as any
}
async function getStatusContextById (id: string): Promise<{ data: mastodonentities.Context }> {
return Vue.http.get(patchApiUri(`/api/v1/statuses/${id}/context`)) as any
}
async function getReBloggedAccountsById (id: string): Promise<{ data: Array<mastodonentities.Account> }> {
return Vue.http.get(patchApiUri(`/api/v1/statuses/${id}/reblogged_by`)) as any
}
async function getFavouritedAccountsById (id: string): Promise<{ data: Array<mastodonentities.Account> }> {
return Vue.http.get(patchApiUri(`/api/v1/statuses/${id}/favourited_by`)) as any
}
async function favouriteStatusById (id: string): Promise<{ data: mastodonentities.Status }> {
return Vue.http.post(patchApiUri(`/api/v1/statuses/${id}/favourite`)) as any
}
async function unFavouriteStatusById (id: string): Promise<{ data: mastodonentities.Status }> {
return Vue.http.post(patchApiUri(`/api/v1/statuses/${id}/unfavourite`)) as any
}
async function reblogStatusById (id: string): Promise<{ data: mastodonentities.Status }> {
return Vue.http.post(patchApiUri(`/api/v1/statuses/${id}/reblog`)) as any
}
async function unReblogStatusById (id: string): Promise<{ data: mastodonentities.Status }> {
return Vue.http.post(patchApiUri(`/api/v1/statuses/${id}/unreblog`)) as any
}
async function deleteStatusById (id: string) {
return Vue.http.delete(patchApiUri(`/api/v1/statuses/${id}`)) as any
}
async function muteStatusById (id: string) {
return Vue.http.post(patchApiUri(`/api/v1/statuses/${id}/mute`)) as any
}
async function unMuteStatusById (id: string) {
return Vue.http.post(patchApiUri(`/api/v1/statuses/${id}/unmute`)) as any
}
async function getStatusCardInfoById (id: string): Promise<{ data: mastodonentities.Card }> {
return Vue.http.get(patchApiUri(`/api/v1/statuses/${id}/card`)) as any
}
export {
getStatusById,
postStatus,
getStatusContextById,
getReBloggedAccountsById,
getFavouritedAccountsById,
favouriteStatusById,
unFavouriteStatusById,
reblogStatusById,
unReblogStatusById,
deleteStatusById,
muteStatusById,
unMuteStatusById,
getStatusCardInfoById
}
================================================
FILE: src/api/streaming.ts
================================================
import store from '@/store'
import { StreamingEventTypes, TimeLineTypes, NotificationTypes, RoutersInfo, I18nTags } from '@/constant'
import { mastodonentities } from "@/interface"
import router from '@/router'
import { extractText, prepareRootStatus } from "@/util"
import i18n from '@/i18n'
class NotificationHandler {
public emit (newNotification: mastodonentities.Notification) {
switch (newNotification.type) {
case NotificationTypes.MENTION : {
return this.emitStatusOperateNotification(newNotification, i18n.t(I18nTags.notifications.mentioned_you))
}
case NotificationTypes.REBLOG : {
return this.emitStatusOperateNotification(newNotification, i18n.t(I18nTags.notifications.boosted_your_status))
}
case NotificationTypes.FAVOURITE : {
// update status info
store.dispatch('fetchStatusById', newNotification.status.id)
return this.emitStatusOperateNotification(newNotification, i18n.t(I18nTags.notifications.favourited_your_status))
}
case NotificationTypes.FOLLOW : {
store.dispatch('updateRelationships', { idList: [newNotification.account.id] })
return this.emitStatusOperateNotification(newNotification, i18n.t(I18nTags.notifications.someone_followed_you))
}
}
}
private emitStatusOperateNotification (newNotification: mastodonentities.Notification, operateTypeString) {
const title = `${this.getFromName(newNotification)} ${operateTypeString}`
const bodyText = newNotification.status ? extractText(newNotification.status.content) : ''
// ignore all muted status's notification
if (newNotification.status && (store.state.appStatus.settings.muteMap.statusList.indexOf(newNotification.status) !== -1)) return
if (store.state.appStatus.settings.muteMap.userList.indexOf(newNotification.account.id) !== -1) return
const nativeNotification = new Notification(title, { body: bodyText, icon: this.getImageUrl(newNotification) })
nativeNotification.addEventListener('click', () => {
if (store.state.appStatus.unreadNotificationCount > 0) {
store.commit('updateUnreadNotificationCount', store.state.appStatus.unreadNotificationCount - 1)
}
this.routeToTargetStatus(newNotification)
})
}
private getFromName (newNotification: mastodonentities.Notification): string {
// account's display name have been formatted
return store.getters['getAccountDisplayName'](newNotification.account)
.replace('<span>', '').replace('</span>', '')
}
private getImageUrl (newNotification: mastodonentities.Notification): string {
return newNotification.account.avatar
}
private async routeToTargetStatus (newNotification: mastodonentities.Notification) {
const targetStatus = await prepareRootStatus(newNotification.status)
router.push({
name: RoutersInfo.statuses.name,
params: {
statusId: targetStatus.id
}
})
}
private routeToTargetAccount () {
}
}
const notificationHandler = new NotificationHandler()
class Streaming {
private userStreamWs: WebSocket
private localStreamWs: WebSocket
private publicStreamWs: WebSocket
private createWsUrl (streamName: string) {
return `wss://${new URL(store.state.mastodonServerUri).hostname}/api/v1/streaming/?stream=${streamName}&access_token=${store.state.OAuthInfo.accessToken}`
}
public openUserConnection () {
const wsUrl = this.createWsUrl('user')
this.userStreamWs = new WebSocket(wsUrl)
this.initEventListener(this.userStreamWs, TimeLineTypes.HOME)
}
public openLocalConnection () {
const wsUrl = this.createWsUrl('public:local')
this.localStreamWs = new WebSocket(wsUrl)
this.initEventListener(this.localStreamWs, TimeLineTypes.LOCAL)
}
public openPublicConnection () {
const wsUrl = this.createWsUrl('public')
this.publicStreamWs = new WebSocket(wsUrl)
this.initEventListener(this.publicStreamWs, TimeLineTypes.PUBLIC)
}
public closeConnection (timeLineType: string) {
const typeToWsMap = {
[TimeLineTypes.HOME]: this.userStreamWs,
[TimeLineTypes.LOCAL]: this.localStreamWs,
[TimeLineTypes.PUBLIC]: this.publicStreamWs
}
typeToWsMap[timeLineType].close()
}
private initEventListener (targetWs: WebSocket, timeLineType, hashName?) {
targetWs.onmessage = (message) => {
if(message.data.length) {
const parsedMessage = JSON.parse(message.data)
switch (parsedMessage.event) {
case StreamingEventTypes.UPDATE : {
return this.updateStatus(JSON.parse(parsedMessage.payload), timeLineType, hashName)
}
case StreamingEventTypes.DELETE : {
return this.deleteStatus(parsedMessage.payload)
}
case StreamingEventTypes.NOTIFICATION : {
return this.emitNotification(JSON.parse(parsedMessage.payload))
}
}
}
}
}
private updateStatus (newStatus: mastodonentities.Status, timeLineType, hashName?) {
if (store.state.statusMap[newStatus.id]) return
// update status map
store.commit('updateStatusMap', { [newStatus.id]: newStatus })
store.dispatch('updateCardMap', newStatus.id)
if (timeLineType === TimeLineTypes.HOME) {
prepareRootStatus(newStatus)
}
// update target timeline list
const targetMutationName = store.state.appStatus.settings.realTimeLoadStatusMode ? 'unShiftTimeLineStatuses' : 'unShiftStreamStatusesPool'
store.commit(targetMutationName, {
newStatusIdList: [newStatus.id],
timeLineType, hashName
})
}
private deleteStatus (statusId: string) {
if (!store.state.statusMap[statusId]) return
// remove from time line
store.commit('deleteStatusFromTimeLine', statusId)
// remove from status map
store.commit('removeStatusFromStatusMapById', statusId)
}
private emitNotification (newNotification: mastodonentities.Notification) {
// update notification list
store.commit('unShiftNotification', [newNotification])
// set notification icon unread
store.commit('updateUnreadNotificationCount', store.state.appStatus.unreadNotificationCount + 1)
// send browser notification
// @ts-ignore
if (window.Notification) {
notificationHandler.emit(newNotification)
}
}
}
export default new Streaming()
================================================
FILE: src/api/timelines.ts
================================================
import Vue from 'vue'
import { patchApiUri, isBaseTimeLine } from '@/util'
import { TimeLineTypes } from '@/constant'
import { mastodonentities } from '@/interface'
const allTimeLineTypeList = [
TimeLineTypes.HOME, TimeLineTypes.PUBLIC, TimeLineTypes.DIRECT, TimeLineTypes.LOCAL,
TimeLineTypes.TAG, TimeLineTypes.LIST
]
async function getTimeLineStatuses ({ timeLineType = '', maxId = '', sinceId = '', hashName = '', limit = 40, local = false} = {}): Promise<{ data: Array<mastodonentities.Status> }> {
if (allTimeLineTypeList.indexOf(timeLineType) === -1) throw new Error('unknown timeline type!')
let urlFragmentString = ''
if (isBaseTimeLine(timeLineType)) {
urlFragmentString = timeLineType
} else {
if (!hashName) throw new Error('need a hash name!')
urlFragmentString = `${timeLineType}/${hashName}`
}
const params: any = { limit: limit }
if (maxId) params.max_id = maxId
if (sinceId) params.since_id = sinceId
if (local) params.local = true
if (timeLineType === TimeLineTypes.LOCAL) {
urlFragmentString = TimeLineTypes.PUBLIC
params.local = true
}
return Vue.http.get(patchApiUri(`/api/v1/timelines/${urlFragmentString}`), {
params
}) as any
}
export {
getTimeLineStatuses
}
================================================
FILE: src/components/Drawer/PeopleResultCard.vue
================================================
<template>
<mu-list-item class="people-result-card" avatar :ripple="false" v-loading="isLoading" data-mu-loading-size="36">
<mu-list-item-action>
<mu-avatar class="people-result-card-avatar" @click="onCheckUserAccountPage(account)">
<img :src="account.avatar" />
</mu-avatar>
</mu-list-item-action>
<mu-list-item-content class="people-result-card-content ellipsis-text" @click="onCheckUserAccountPage(account)">
<mu-list-item-title class="user-display-name primary-read-text-color"
v-html="getAccountDisplayName(account)" />
<mu-list-item-sub-title class="user-at-name secondary-read-text-color"
v-html="`@${getAccountAtName(account)}`" />
</mu-list-item-content>
<mu-list-item-action v-if="currentUserAccount.id !== account.id && relationships[account.id] && !relationships[account.id].following">
<mu-icon class="operate-btn" value="person_add" @click="onFollowingAccount"/>
</mu-list-item-action>
<mu-list-item-action v-if="currentUserAccount.id !== account.id && relationships[account.id] && relationships[account.id].following">
<mu-icon class="operate-btn secondary-theme-text-color" value="person_add_disabled" @click="onUnFollowingAccount"/>
</mu-list-item-action>
</mu-list-item>
</template>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator'
import { State, Action, Getter } from 'vuex-class'
import { mastodonentities } from '@/interface'
@Component({})
class PeopleResultCard extends Vue {
isLoading: boolean = false
@Prop() account: mastodonentities.Account
@State('currentUserAccount') currentUserAccount: mastodonentities.AuthenticatedAccount
@State('relationships') relationships: {
[id: string]: mastodonentities.Relationship
}
@Getter('getAccountDisplayName') getAccountDisplayName
@Getter('getAccountAtName') getAccountAtName
@Action('followAccountById') followAccountById
@Action('unFollowAccountById') unFollowAccountById
onCheckUserAccountPage (account: mastodonentities.Account) {
window.open(account.url, "_blank")
}
async onFollowingAccount () {
this.isLoading = true
await this.followAccountById(this.account.id)
this.isLoading = false
}
async onUnFollowingAccount () {
this.isLoading = true
await this.unFollowAccountById(this.account.id)
this.isLoading = false
}
}
export default PeopleResultCard
</script>
<style lang="less" scoped>
.ellipsis-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.operate-btn {
cursor: pointer;
}
.people-result-card {
position: relative;
.people-result-card-avatar {
cursor: pointer;
}
.people-result-card-content {
cursor: pointer;
.user-display-name {
display: inline;
}
&:hover {
.user-display-name, .user-at-name {
text-decoration: underline;
}
}
}
}
</style>
================================================
FILE: src/components/Drawer/Search.vue
================================================
<template>
<div class="search-area-container">
<div class="search-bar">
<mu-icon value="search" style="margin-right: 10px"/>
<mu-text-field class="search-input" v-model="searchKey"
@keydown.stop="onKeyDown"
@keydown.enter.stop="onSearch" :placeholder="$t($i18nTags.drawer.search_input_placeholder)"
:action-icon="shouldShowSearchActionIcon ? 'search' : 'cancel'"
:action-click="onSearchInputActionClick"/>
</div>
<div class="search-results default-theme-bg-color" :style="resultPanelStyle">
<mu-list>
<mu-sub-header>{{$t($i18nTags.drawer.search_result_people_label)}}</mu-sub-header>
<people-result-card v-for="(account, index) in searchResults.accounts" :account="account" :key="index"/>
</mu-list>
<mu-divider></mu-divider>
<mu-list>
<mu-sub-header>{{$t($i18nTags.drawer.search_result_hashtag_label)}}</mu-sub-header>
<mu-list-item v-for="(hashTag, index) in searchResults.hashtags" :key="index"
class="hashtag-result-card" :ripple="false">
<mu-list-item-title class="hash-tag ellipsis-text primary-read-text-color"
v-html="hashTag" @click="onCheckHashTagTimeLine(hashTag)"/>
<mu-list-item-action v-if="!appStatus.settings.tags.includes(hashTag)">
<mu-icon class="operate-btn" value="playlist_add" @click="onSaveHashTag(hashTag)"/>
</mu-list-item-action>
</mu-list-item>
</mu-list>
</div>
</div>
</template>
<script lang="ts">
import { Vue, Component } from 'vue-property-decorator'
import { Getter, State, Action, Mutation } from 'vuex-class'
import * as Api from '@/api'
import { mastodonentities } from '@/interface'
import { UiWidthCheckConstants } from '@/constant'
import PeopleResultCard from './PeopleResultCard'
@Component({
components: {
'people-result-card': PeopleResultCard
}
})
class Search extends Vue {
$progress
$t
$i18nTags
$router
$routersInfo
@State('relationships') relationships: {
[id: string]: mastodonentities.Relationship
}
@State('appStatus') appStatus
@Getter('isMobileMode') isMobileMode
@Action('updateRelationships') updateRelationships
@Mutation('updateDrawerOpenStatus') updateDrawerOpenStatus
@Mutation('updateTags') updateTags
searchKey: string = ''
currentSearchKey: string = ''
shouldShowResultPanel: boolean = false
searchResults: mastodonentities.SearchResults = {
accounts: [],
hashtags: [],
statuses: []
}
get shouldShowSearchActionIcon () {
return !this.searchKey.length && !this.shouldShowResultPanel
}
get resultPanelStyle () {
return {
height: `calc(100vh - 68px${this.isMobileMode ? '' : ' - 64px'})`,
left: this.shouldShowResultPanel ? '0' : `-${this.isMobileMode ? UiWidthCheckConstants.DRAWER_MOBILE_WIDTH : UiWidthCheckConstants.DRAWER_DESKTOP_WIDTH}px`
}
}
async onSearchInputActionClick () {
if (this.shouldShowSearchActionIcon) return
this.searchKey = ''
this.currentSearchKey = ''
this.shouldShowResultPanel = false
}
async onSearch () {
if (this.searchKey === this.currentSearchKey) return
this.currentSearchKey = this.searchKey
this.$progress.start()
try {
const result = await Api.search.getSearchResults(this.searchKey)
this.searchResults = result.data
this.updateRelationship()
this.shouldShowResultPanel = true
this.$progress.done()
} catch (e) {
this.$progress.done()
}
}
updateRelationship () {
const newAccountResultList = this.searchResults.accounts.filter(account => !this.relationships[account.id])
this.updateRelationships({ idList: newAccountResultList.map(account => account.id) })
}
onCheckHashTagTimeLine (hashTagName: string) {
this.$router.push({
name: this.$routersInfo.tagtimelines.name,
params: {
tagName: hashTagName
}
})
if (this.isMobileMode) this.updateDrawerOpenStatus(false)
}
onSaveHashTag (hashTagName: string) {
this.updateTags([...this.appStatus.settings.tags, hashTagName])
}
onKeyDown () {}
}
export default Search
</script>
<style lang="less" scoped>
.ellipsis-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.operate-btn {
cursor: pointer;
}
.search-area-container {
position: relative;
.search-bar {
display: flex;
align-items: center;
height: 68px;
padding: 0 16px;
.search-input {
min-height: unset;
margin: 0;
padding: 0;
}
}
.search-results {
position: absolute;
// todo 在iPhone X上失效
overflow: auto;
-webkit-overflow-scrolling: touch;
top: 68px;
width: 100%;
z-index: 1;
-webkit-transition: left .45s cubic-bezier(.23,1,.32,1);
-moz-transition: left .45s cubic-bezier(.23,1,.32,1);
-ms-transition: left .45s cubic-bezier(.23,1,.32,1);
-o-transition: left .45s cubic-bezier(.23,1,.32,1);
transition: left .45s cubic-bezier(.23,1,.32,1);
.hashtag-result-card {
.hash-tag {
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
}
}
</style>
<style lang="less">
.search-area-container {
.search-input {
.mu-input-line, .mu-input-focus-line {
display: none;
}
}
}
</style>
================================================
FILE: src/components/Drawer/index.vue
================================================
<template>
<mu-drawer class="cuckoo-drawer default-theme-bg-color primary-read-text-color" :open.sync="appStatus.isDrawerOpened" :style="drawerStyle"
:docked="shouldDrawerDocked" :z-depth="shouldDrawerDocked ? 0 : 16">
<search />
<mu-divider />
<mu-list :value="currentListValue" toggle-nested>
<mu-list-item button v-for="(info, index) in baseRouterInfoList"
:value="info.value"
:nested="!!info.hashList" :ripple="!info.hashList"
:key="index" @click="!info.hashList && onBaseRouteItemClick(info.value)">
<mu-list-item-action>
<mu-icon :value="info.icon"/>
</mu-list-item-action>
<mu-list-item-title>{{$t(info.title)}}</mu-list-item-title>
<mu-list-item-action v-if="!!info.hashList">
<mu-icon class="toggle-icon" size="24" value="keyboard_arrow_down" />
</mu-list-item-action>
<mu-list-item class="hash-list-item" v-if="info.hashList" slot="nested" button
v-for="(hashName, index) in info.hashList"
:value="info.to + '/' + hashName"
:key="index" @click="onHashRouteItemClick(info.value, hashName)">
<mu-list-item-title># {{hashName}}</mu-list-item-title>
<mu-list-item-action>
<mu-button class="delete-hash-btn" icon @click.stop="onDeleteHash(hashName)">
<mu-icon value="delete" />
</mu-button>
</mu-list-item-action>
</mu-list-item>
</mu-list-item>
</mu-list>
<mu-divider />
<mu-list class="secondary-list">
<mu-list-item button :to="$routersInfo.settings.path" @click="onSecondaryItemClick">
<mu-list-item-title class="secondary-read-text-color">{{$t($i18nTags.drawer.settings)}}</mu-list-item-title>
</mu-list-item>
</mu-list>
<div class="bottom-info-area secondary-read-text-color">
<div style="margin-bottom: 6px">
<a class="secondary-read-text-color">
©{{(new Date().getFullYear()).toString().split('').reverse().join('') }} Cuckoo</a>
•
<a class="secondary-read-text-color link-text" href="https://github.com/NanaMorse/Cuckoo.Plus" target="_blank">Github</a>
</div>
<a class="secondary-read-text-color link-text" :href="mastodonServerUri" target="_blank">{{$t($i18nTags.drawer.toHostInstance)}}</a>
<div style="margin-top: 6px">
<a class="secondary-read-text-color link-text" @click="onTryLogout">{{$t($i18nTags.drawer.logout)}}</a>
</div>
</div>
</mu-drawer>
</template>
<script lang="ts">
import { Vue, Component, Watch } from 'vue-property-decorator'
import { State, Mutation, Action } from 'vuex-class'
import { isBaseTimeLine } from '@/util'
import { TimeLineTypes, UiWidthCheckConstants, RoutersInfo, I18nTags } from '@/constant'
import Search from './Search'
import store from '@/store'
const baseRouterInfoList = [
{
value: TimeLineTypes.HOME,
title: I18nTags.drawer.home,
icon: 'home',
to: '/timelines/home'
},
{
value: TimeLineTypes.PUBLIC,
title: I18nTags.drawer.public,
icon: 'public',
to: '/timelines/public'
},
{
value: TimeLineTypes.LOCAL,
title: I18nTags.drawer.local,
icon: 'people',
to: '/timelines/local'
},
{
value: TimeLineTypes.TAG,
title: I18nTags.drawer.tag,
icon: 'loyalty',
to: '/timelines/tag',
hashList: []
},
{
value: 'profile',
title: I18nTags.drawer.profile,
icon: 'person'
}
]
@Component({
components: {
'search': Search
}
})
class Drawer extends Vue {
$route
$router
$routersInfo
$progress
$toast
$confirm
$t
$i18nTags
@State('currentUserAccount') currentUserAccount
@State('appStatus') appStatus
@State('mastodonServerUri') mastodonServerUri
@Mutation('updateDrawerOpenStatus') updateDrawerOpenStatus
@Mutation('updateTags') updateTags
@Action('updateTimeLineStatuses') updateTimeLineStatuses
@Watch('shouldDrawerDocked')
onShouldDrawerDockedChanged () {
if (!this.shouldDrawerDocked && this.appStatus.isDrawerOpened) {
this.updateDrawerOpenStatus(false)
}
}
get shouldDrawerDocked () {
return this.appStatus.documentWidth > UiWidthCheckConstants.DRAWER_DOCKING_BOUNDARY
}
get baseRouterInfoList () {
// @ts-ignore
baseRouterInfoList.find(info => info.value === TimeLineTypes.TAG).hashList = this.appStatus.settings.tags
return baseRouterInfoList
}
get drawerStyle () {
if (this.shouldDrawerDocked) {
return {
top: '64px',
width: `${UiWidthCheckConstants.DRAWER_DESKTOP_WIDTH}px`
}
} else {
return {
width: `${UiWidthCheckConstants.DRAWER_MOBILE_WIDTH}px`
}
}
}
get currentListValue () {
if (this.$route.name === RoutersInfo.tagtimelines.name) {
return this.$route.path
} else {
const currentRouterInfo = baseRouterInfoList.find(routerInfo => routerInfo.to === this.$route.path)
if (currentRouterInfo) return currentRouterInfo.value
}
}
async onBaseRouteItemClick (clickedRouterValue: string) {
if (clickedRouterValue === 'profile') {
// todo
// this.$router.push({
// name: this.$routersInfo.accounts.name,
// params: {
// accountId: this.currentUserAccount.id
// }
// })
return window.open(this.currentUserAccount.url, '_blank')
} else {
const targetPath = baseRouterInfoList.find(routerInfo => routerInfo.value === clickedRouterValue).to
if (isBaseTimeLine(clickedRouterValue) && (targetPath === this.$route.path)) {
this.fetchTimeLineStatuses(clickedRouterValue)
}
if (!this.shouldDrawerDocked) this.updateDrawerOpenStatus(false)
this.$router.push(targetPath)
window.scrollTo(0, 0)
}
}
async onHashRouteItemClick (clickedRouterValue: string, hashName: string) {
const targetPath = baseRouterInfoList.find(routerInfo => routerInfo.value === clickedRouterValue).to + '/' + hashName
if (targetPath === this.$route.path) {
this.fetchTimeLineStatuses(clickedRouterValue, hashName)
}
if (!this.shouldDrawerDocked) this.updateDrawerOpenStatus(false)
this.$router.push(targetPath)
window.scrollTo(0, 0)
}
onSecondaryItemClick () {
if (!this.shouldDrawerDocked) this.updateDrawerOpenStatus(false)
window.scrollTo(0, 0)
}
async onTryLogout () {
const doLogout = (await this.$confirm(this.$t(this.$i18nTags.drawer.do_logout_message_confirm), {
okLabel: this.$t(this.$i18nTags.drawer.do_logout_message_yes),
cancelLabel: this.$t(this.$i18nTags.drawer.do_logout_message_no),
})).result
if (doLogout) {
localStorage.clear()
location.href = '/'
}
}
onDeleteHash (hashName: string) {
// todo only tag has hash now
const newTags = [...this.appStatus.settings.tags]
newTags.splice(newTags.indexOf(hashName as any), 1)
this.updateTags(newTags)
}
/**
* @desc if clicked timeline item is just current timeline
* */
async fetchTimeLineStatuses (timeLineType: string, hashName: string = '') {
this.$progress.start()
await this.updateTimeLineStatuses({
isFetchMore: true,
timeLineType, hashName
})
this.$progress.done()
}
onOpenHostInstance () {
window.open(this.mastodonServerUri, '_blank');
}
}
export default Drawer
</script>
<style lang="less" scoped>
.cuckoo-drawer {
.hash-list-item {
.delete-hash-btn {
display: none;
}
&:hover {
.delete-hash-btn {
display: unset;
}
}
}
.bottom-info-area {
position: absolute;
bottom: 0;
margin: 0 0 24px 24px;
font-size: 13px;
.link-text {
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
}
</style>
<style lang="less">
.cuckoo-drawer {
// todo current size is not fit to every screen
//background: url("https://i.imgur.com/vKv5bn5.png") no-repeat left bottom;
//background-size: 42%;
.mu-item-wrapper {
-webkit-transition: background-color .3s cubic-bezier(0,0,0.2,1);
-moz-transition: background-color .3s cubic-bezier(0,0,0.2,1);
-ms-transition: background-color .3s cubic-bezier(0,0,0.2,1);
-o-transition: background-color .3s cubic-bezier(0,0,0.2,1);
transition: background-color .3s cubic-bezier(0,0,0.2,1);
}
.toggle-icon {
transform: rotate(0);
transition: transform .3s cubic-bezier(.23,1,.32,1),-webkit-transform .3s cubic-bezier(.23,1,.32,1)
}
.mu-item__open .toggle-icon {
transform: rotate(180deg);
}
}
</style>
================================================
FILE: src/components/Header.vue
================================================
<template>
<div class="cuckoo-header-container">
<mu-appbar class="header" :class="shouldUseSecondaryThemeHeader && 'dialog-theme-bg-color'" color="primary" @click.native="onHeaderBarClick">
<mu-button v-if="isOAuthUser" icon @click.stop="onMenuBtnClick" slot="left">
<mu-icon value="menu"></mu-icon>
</mu-button>
<div class="host-mastodon-url cuckoo-hub-logo" v-if="isCuckooHubTheme">
<span>Cuck</span><span>Hub</span>
</div>
<span v-if="!isCuckooHubTheme" class="host-mastodon-url" @click="onHostMastodonUrlClick">{{parsedMastodonServerUri}}</span>
<mu-button v-if="isOAuthUser" ref="notificationBtn" icon @click.stop="onOpenNotificationPanel" slot="right">
<mu-icon v-if="appStatus.unreadNotificationCount === 0" value="notifications"></mu-icon>
<mu-badge class="notification-badge" v-if="appStatus.unreadNotificationCount > 0" :content="String(appStatus.unreadNotificationCount)" circle color="primary" />
</mu-button>
<mu-popover v-if="isOAuthUser" v-show="showNotificationAsPopOver"
cover lazy placement="left-start" style="width: 420px"
:open="appStatus.isNotificationsPanelOpened && showNotificationAsPopOver"
@close="updateNotificationsPanelStatus(false)" :trigger="notificationBtnTrigger">
<notifications />
</mu-popover>
<mu-dialog v-if="isOAuthUser" v-show="!showNotificationAsPopOver" :overlay="false"
:open="appStatus.isNotificationsPanelOpened && !showNotificationAsPopOver"
:fullscreen="true" transition="slide-bottom">
<mu-appbar color="primary" title="Notifications" v-show="shouldShowNotificationDialogHeader">
<mu-button slot="left" icon @click="updateNotificationsPanelStatus(false)">
<mu-icon value="close" />
</mu-button>
<mu-button slot="right" icon @click="onFetchMoreNotifications">
<mu-icon value="refresh" />
</mu-button>
</mu-appbar>
<notifications :style="notificationContainerStyle" :hideHeader="true" @shouldShowTargetStatusChanged="onDialogNotificationShowStatusChanged"/>
</mu-dialog>
<span class="route-info" v-if="shouldShowRouteInfo">{{pathToRouteInfo[$route.path].name}}</span>
</mu-appbar>
</div>
</template>
<script lang="ts">
import { Vue, Component, Watch } from 'vue-property-decorator'
import { State, Mutation, Action, Getter } from 'vuex-class'
import { TimeLineTypes, RoutersInfo, UiWidthCheckConstants, ThemeNames } from '@/constant'
import { cuckoostore } from '@/interface'
import { animatedScrollTo } from '@/util'
import Notifications from '@/components/Notifications/index'
// todo 统一位置管理
const pathToRouteInfo = {
'/timelines/home': {
name: 'Home'
},
'/timelines/public': {
name: 'Public'
},
'/timelines/local': {
name: 'Local'
}
}
@Component({
components: {
'notifications': Notifications
}
})
class Header extends Vue {
$refs: {
notificationBtn: any,
}
$router
$route
$progress
notificationBtnTrigger: HTMLButtonElement = null
@State('appStatus') appStatus
@State('mastodonServerUri') mastodonServerUri
@Action('updateNotifications') updateNotifications
@Getter('isOAuthUser') isOAuthUser
@Mutation('updateDrawerOpenStatus') updateDrawerOpenStatus
@Mutation('updateNotificationsPanelStatus') updateNotificationsPanelStatus
@Mutation('updateUnreadNotificationCount') updateUnreadNotificationCount
pathToRouteInfo = pathToRouteInfo
shouldShowNotificationDialogHeader: boolean = true
@Watch('$route')
onRouteChanged () {
if (!this.isOAuthUser) return
this.updateNotificationsPanelStatus(false)
}
get shouldShowRouteInfo () {
return this.isOAuthUser && (this.appStatus.documentWidth > 600) && this.pathToRouteInfo[this.$route.path]
}
get parsedMastodonServerUri () {
if (!this.isOAuthUser) {
return 'Cuckoo.Plus'
}
const url = new URL(this.mastodonServerUri)
return url.host.replace(url.host[0], (c) => c.toUpperCase())
}
get showNotificationAsPopOver (): boolean {
return this.appStatus.documentWidth > UiWidthCheckConstants.NOTIFICATION_DIALOG_TOGGLE_WIDTH
}
get notificationContainerStyle () {
return {
height: this.shouldShowNotificationDialogHeader ? 'auto' : '100%'
}
}
get shouldUseSecondaryThemeHeader () {
return this.isCuckooHubTheme
}
get isCuckooHubTheme () {
return this.appStatus.settings.theme === ThemeNames.CUCKOO_HUB
}
mounted () {
if (this.isOAuthUser) {
this.notificationBtnTrigger = this.$refs.notificationBtn.$el
}
}
onMenuBtnClick () {
this.updateDrawerOpenStatus(!this.appStatus.isDrawerOpened)
}
onHostMastodonUrlClick () {
this.$router.push({ path: '/timelines/home' })
}
onHeaderBarClick () {
animatedScrollTo(document.querySelector('html'), 0, 400)
}
onOpenNotificationPanel () {
this.onFetchMoreNotifications()
this.updateUnreadNotificationCount(0)
this.updateNotificationsPanelStatus(!this.appStatus.isNotificationsPanelOpened)
}
onDialogNotificationShowStatusChanged (val) {
this.shouldShowNotificationDialogHeader = !val
}
async onFetchMoreNotifications() {
this.$progress.start()
await this.updateNotifications({
isFetchMore: true
})
this.$progress.done()
}
}
export default Header
</script>
<style lang="less" scoped>
.header {
padding-left: 8px;
position: fixed;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 20141223;
.host-mastodon-url {
cursor: pointer;
}
.cuckoo-hub-logo {
span:first-child {
padding: 5px 5px;
font-weight: 600;
}
span:last-child {
padding: 5px 10px;
background-color: #FF9900;
border-radius: 7px;
font-weight: 700;
}
}
.route-info {
height: 32px;
line-height: 32px;
padding-left: 10px;
margin-left: 20px;
}
.search-input-area {
width: 720px;
display: flex;
margin-left: 28px;
align-items: center;
.pre-fix-icon {
margin-left: 10px;
}
.search-input {
margin: 0;
padding: 0;
padding-left: 10px;
}
}
}
</style>
<style lang="less">
.cuckoo-header-container {
.mu-appbar-title {
display: flex;
align-items: center;
padding-left: 0;
.search-input-area {
.mu-text-field-input {
height: 48px;
}
.mu-input-line, .mu-input-focus-line {
display: none;
}
}
}
.notification-badge {
.mu-badge {
border: 2px solid;
}
}
}
</style>
================================================
FILE: src/components/Input.vue
================================================
<template>
<div class="cuckoo-input-container">
<textarea v-show="shouldShowSpoilerTextInputArea" ref="spoilerTextArea"
class="auto-size-text-area spoiler-text-area base-theme-bg-color"
v-model="spoilerTextValue" :placeholder="$t($i18nTags.common.write_your_warning_here)"/>
<textarea ref="textArea" class="auto-size-text-area" v-model="textValue"
@keydown.stop="onKeyDown"
@keydown.ctrl.enter="onQuickSubmit" @input="onInput"
@keydown.38="onMinisSelectedResultIndex" @keydown.40="onPlusSelectedResultIndex"
@keydown.enter="onSelectedSearchResult" @click="onTextAreaClick"
:placeholder="placeholder"/>
<div v-if="uploadProcesses.length" class="media-area" :class="{ 'single-media-area': uploadProcesses.length === 1 }">
<div class="media-item" :key="index"
v-for="(processInfo, index) in uploadProcesses">
<div class="media-loading-wrapper" v-loading="!processInfo.uploadResult">
<img v-if="uploadFileDataUrlList[index]" :src="uploadFileDataUrlList[index]"/>
</div>
<div class="remove-icon-wrapper" @click="onRemoveMediaFileByIndex(index)">
<svg height="24px" width="24px" viewBox="0 0 48 48">
<circle fill="#fefefe" cx="24" cy="24" r="24"></circle>
<path fill="#000" d="M24,4C12.9,4,4,12.9,4,24s8.9,20,20,20s20-9,20-20S35,4,24,4z M34,31.2L31.2,34L24,26.8L16.8,34L14,31.2l7.2-7.2L14,16.8l2.8-2.8l7.2,7.2l7.2-7.2l2.8,2.8L26.8,24L34,31.2z"></path>
</svg>
</div>
</div>
</div>
<mu-list v-if="shouldShowAccountSearchResultList" v-loading="isLoadingSearchResult"
class="at-account-search-result-list dialog-theme-bg-color"
:style="accountSearchResultListStyle">
<mu-list-item avatar button :ripple="false" :key="index"
@hover="currentSelectedResultIndex = index"
@click.stop="onSelectedSearchResult"
:class="{ 'active': currentSelectedResultIndex === index }"
v-for="(account, index) in atAccountSearchResultList">
<mu-list-item-action>
<mu-avatar>
<img :src="account.avatar">
</mu-avatar>
</mu-list-item-action>
<mu-list-item-title v-html="getSearchUserFullName(account)" />
</mu-list-item>
</mu-list>
</div>
</template>
<script lang="ts">
import { Vue, Component, Prop, Watch } from 'vue-property-decorator'
import { mastodonentities } from '@/interface'
import * as Api from '@/api'
import { formatAccountDisplayName, resetImageFileSizeForUpload } from '@/util'
const autosize = require('autosize')
const getCaretCoordinates = require('textarea-caret');
const maxImageSize = 7.8 * 1024 * 1024
const searchResultListMaxHeight = 240
const listItemHeight = 48
const listVerticalPadding = 0
const listMargin = 4
const atCheckRegex = /\s@\S*|^@\S*/
@Component({})
class Input extends Vue {
$refs: {
textArea: HTMLTextAreaElement
spoilerTextArea: HTMLTextAreaElement
}
$toast
@Prop() text: string
@Prop() uploadProcesses: Array<{
file: File,
hasStartedUpload: boolean,
uploadResult: mastodonentities.Attachment
}>
@Prop() shouldShowSpoilerTextInputArea: boolean
@Prop() spoilerText: string
@Prop() placeholder: string
@Prop({ default: () => [] }) presetAtAccounts: Array<mastodonentities.Account>
uploadFileDataUrlList: Array<string> = []
atAccountSearchResultList: Array<mastodonentities.Account> = []
currentSelectedResultIndex: number = 0
currentSearchTextPosition: [number, number] = null
insertedAcctList: Array<string> = []
isLoadingSearchResult = false
get textValue () {
return this.text
}
set textValue (val) {
this.$emit('update:text', val)
}
get spoilerTextValue () {
return this.spoilerText
}
set spoilerTextValue (val) {
this.$emit('update:spoilerText', val)
}
get shouldShowAccountSearchResultList () {
return this.atAccountSearchResultList.length !== 0
}
accountSearchResultListStyle = null
@Watch('uploadProcesses')
startUploadProcess () {
const uploadProcessesCopy = [...this.uploadProcesses]
uploadProcessesCopy.forEach(async (processInfo, index) => {
// update data url list
if (!this.uploadFileDataUrlList[index] && !processInfo.hasStartedUpload) {
const resolvedFile = await resetImageFileSizeForUpload(processInfo.file)
if (resolvedFile !== processInfo.file) {
uploadProcessesCopy[index].file = resolvedFile as any
}
const fileReader = new FileReader()
fileReader.readAsDataURL(processInfo.file)
// @ts-ignore
fileReader.onload = () => Vue.set(this.uploadFileDataUrlList, index, fileReader.result)
}
// start upload process
if (!processInfo.hasStartedUpload) {
uploadProcessesCopy[index].hasStartedUpload = true
this.$emit('update:uploadProcesses', uploadProcessesCopy)
const formData = new FormData()
formData.append('file', processInfo.file)
try {
const result = await Api.media.postMediaFile(formData)
uploadProcessesCopy[index].uploadResult = result.data
this.$emit('update:uploadProcesses', uploadProcessesCopy)
} catch (e) {
this.onRemoveMediaFileByIndex(index)
this.$toast.error(e.data.error)
}
}
})
}
public focus () {
this.$nextTick(() => {
this.$refs.textArea.focus()
})
}
public updateSize () {
this.$nextTick(() => {
this.$refs.textArea.dispatchEvent(new Event('autosize:update'))
this.$refs.spoilerTextArea.dispatchEvent(new Event('autosize:update'))
})
}
mounted () {
this.startUploadProcess()
this.insertedAcctList = this.presetAtAccounts.map(accounts => accounts.acct)
autosize(this.$refs.textArea)
autosize(this.$refs.spoilerTextArea)
}
onKeyDown (e: KeyboardEvent) {
if (e.key === 'Escape') {
this.$emit('esc')
}
}
onQuickSubmit () {
this.$emit('submit')
}
onRemoveMediaFileByIndex (index: number) {
const uploadProcessesCopy = [...this.uploadProcesses]
uploadProcessesCopy.splice(index, 1)
// todo update upload processes
this.$emit('update:uploadProcesses', uploadProcessesCopy)
// update uploadFileDataUrlList
this.uploadFileDataUrlList.splice(index, 1)
}
onInput () {
this.searchAtUsers()
}
onTextAreaClick () {
if (this.shouldShowAccountSearchResultList) {
this.closeSearchAtUsersList()
}
}
onPlusSelectedResultIndex (e: KeyboardEvent) {
if (this.shouldShowAccountSearchResultList) {
e.preventDefault()
if (this.currentSelectedResultIndex === (this.atAccountSearchResultList.length - 1)) {
this.currentSelectedResultIndex = 0
} else {
this.currentSelectedResultIndex = this.currentSelectedResultIndex + 1
}
}
}
onMinisSelectedResultIndex (e: KeyboardEvent) {
if (this.shouldShowAccountSearchResultList) {
e.preventDefault()
if (this.currentSelectedResultIndex === 0) {
this.currentSelectedResultIndex = this.atAccountSearchResultList.length - 1
} else {
this.currentSelectedResultIndex = this.currentSelectedResultIndex - 1
}
}
}
onSelectedSearchResult (e: KeyboardEvent) {
if (this.shouldShowAccountSearchResultList) {
e.preventDefault()
const preText = this.textValue.substring(0, this.currentSearchTextPosition[0])
const insertText = `@${this.atAccountSearchResultList[this.currentSelectedResultIndex].acct}`
const endText = this.textValue.substring(this.currentSearchTextPosition[1])
this.textValue = `${preText}${insertText}${endText} `
this.insertedAcctList.push(this.atAccountSearchResultList[this.currentSelectedResultIndex].acct)
this.closeSearchAtUsersList()
this.focus()
this.updateSize()
}
}
getSearchUserFullName (account: mastodonentities.Account) {
return `${formatAccountDisplayName(account)} <span class="at-name secondary-read-text-color">@${account.acct}</span>`
}
getAccountListTopPosition () {
const { top, height } = getCaretCoordinates(this.$refs.textArea, this.$refs.textArea.selectionEnd)
const { height: offsetHeight, top: offsetTop } = this.$refs.textArea.getBoundingClientRect()
let topPosition = top + height + listMargin
if (innerHeight - offsetTop - offsetHeight - top < searchResultListMaxHeight) {
const listHeight = Math.min(listItemHeight * this.atAccountSearchResultList.length + listVerticalPadding * 2, searchResultListMaxHeight)
topPosition = -listHeight - listMargin
}
return `${topPosition}px`
}
getSearchAtUsersKeyWords (): string {
let selectionEnd = this.$refs.textArea.selectionEnd
if (this.textValue[selectionEnd - 1] === ' ') return
const len = this.textValue.length
for (; selectionEnd < len; selectionEnd ++) {
if (this.textValue[selectionEnd] === ' ') {
break
}
}
const textBeforeSelection = this.textValue.slice(0, selectionEnd).split(' ').pop().split('\n').pop()
if (textBeforeSelection.match(atCheckRegex)) {
this.currentSearchTextPosition = [selectionEnd - textBeforeSelection.length, selectionEnd]
return textBeforeSelection.slice(1)
}
}
searchAtUsers () {
this.$nextTick(async () => {
const searchUsersKeyWords = this.getSearchAtUsersKeyWords()
if (searchUsersKeyWords === undefined || this.insertedAcctList.indexOf(searchUsersKeyWords) !== -1) {
this.isLoadingSearchResult = false
return this.closeSearchAtUsersList()
}
if (searchUsersKeyWords === '') {
Api.search.abortSearch()
this.isLoadingSearchResult = false
this.atAccountSearchResultList = [...this.presetAtAccounts]
} else {
// search for accounts
try {
this.isLoadingSearchResult = true
const result = await Api.search.getSearchResults(searchUsersKeyWords)
this.isLoadingSearchResult = false
this.atAccountSearchResultList = [...result.data.accounts]
} catch (e) {
}
}
if (this.atAccountSearchResultList.length) {
this.accountSearchResultListStyle = { top: this.getAccountListTopPosition() }
}
})
}
closeSearchAtUsersList () {
this.atAccountSearchResultList = []
this.currentSelectedResultIndex = 0
this.currentSearchTextPosition = [0, 0]
}
beforeDestroy () {
this.$refs.textArea.dispatchEvent(new Event('autosize:destroy'))
this.$refs.spoilerTextArea.dispatchEvent(new Event('autosize:destroy'))
}
}
export default Input
</script>
<style lang="less" scoped>
.cuckoo-input-container {
width: 100%;
position: relative;
.spoiler-text-area {
height: 38px;
padding: 10px;
border-radius: 4px;
}
.media-area {
padding-left: 16px;
.media-loading-wrapper {
height: 100%;
position: relative;
img {
width: auto;
}
}
.remove-icon-wrapper {
cursor: pointer;
position: absolute;
right: 12px;
top: 12px;
z-index: 20141223;
}
}
.at-account-search-result-list {
width: 100%;
max-height: 240px;
position: absolute;
box-shadow: 0 2px 5px 0 rgba(0,0,0,0.26);
z-index: 1;
padding: 0;
}
}
</style>
<style lang="less">
.at-account-search-result-list {
.active > .mu-item-wrapper {
background-color: rgba(0, 0, 0, .1) !important;
}
.mu-item-wrapper {
&.hover {
background-color: unset;
}
.mu-item.has-avatar {
height: 48px;
padding: 0 10px;
}
}
}
</style>
================================================
FILE: src/components/Notifications/Card.vue
================================================
<template>
<mu-list-item :style="notificationCardStyle" v-loading="isLoading"
@click="onNotificationCardClick(notification)"
class="notification-card dialog-theme-bg-color" avatar button :ripple="false">
<mu-list-item-action class="user-avatar-area">
<mu-avatar class="user-avatar" @click.stop="onCheckUserAccountPage(notification.account)">
<img :src="notification.account.avatar" />
</mu-avatar>
</mu-list-item-action>
<mu-list-item-content>
<mu-list-item-title class="user-display-name primary-read-text-color"
v-html="getAccountDisplayName(notification.account)"
@click.stop="onCheckUserAccountPage(notification.account)" />
<mu-list-item-sub-title class="notification-content primary-read-text-color"
v-html="getNotificationSubTitle(notification)" @click.prevent="onNotificationContentClick"/>
</mu-list-item-content>
<mu-list-item-action v-if="shouldShowFollowOperateBtn(notification, followOperateBtnTypes.FOLLOW)">
<mu-icon @click.stop="onFollowingAccount(notification.account.id)" class="follow-action" value="person_add" />
</mu-list-item-action>
<mu-list-item-action v-if="shouldShowFollowOperateBtn(notification, followOperateBtnTypes.UN_FOLLOW)">
<mu-icon @click.stop="onUnFollowingAccount(notification.account.id)" class="follow-action secondary-theme-text-color" value="person_add_disabled" />
</mu-list-item-action>
</mu-list-item>
</template>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator'
import { State, Getter, Action } from 'vuex-class'
import { NotificationTypes, ThemeNames, I18nTags } from '@/constant'
import { mastodonentities } from '@/interface'
import { prepareRootStatus, formatHtml } from "@/util"
const notificationTypeToI18nTagsMap = {
[NotificationTypes.FAVOURITE]: I18nTags.notifications.favourited_your_status,
[NotificationTypes.REBLOG]: I18nTags.notifications.boosted_your_status
}
@Component({})
class Card extends Vue {
$t
$i18nTags
isLoadingSingleCard: boolean = false
isLoading: boolean = false
followOperateBtnTypes = {
FOLLOW: 'FOLLOW',
UN_FOLLOW: 'UN_FOLLOW'
}
@Prop() notification: mastodonentities.Notification
@Action('followAccountById') followAccountById
@Action('unFollowAccountById') unFollowAccountById
@State('appStatus') appStatus
@State('relationships') relationships: {
[id: string]: mastodonentities.Relationship
}
@Getter('getAccountDisplayName') getAccountDisplayName
onNotificationContentClick () {}
get notificationCardStyle () {
const themeToStyle: any = {
[ThemeNames.GOOGLE_PLUS]: {
backgroundColor: '#fff'
},
[ThemeNames.GREEN_LIGHT]: {
backgroundColor: '#fff'
}
}
themeToStyle[this.appStatus.settings.theme] = themeToStyle[this.appStatus.settings.theme] || {}
themeToStyle[this.appStatus.settings.theme].position = this.isLoadingSingleCard ? 'relative' : ''
return themeToStyle[this.appStatus.settings.theme]
}
onCheckUserAccountPage (account: mastodonentities.Account) {
window.open(account.url, "_blank")
}
getNotificationSubTitle (notification) {
switch (notification.type) {
case NotificationTypes.FOLLOW: {
return `${this.getAccountDisplayName(notification.account)} ${this.$t(this.$i18nTags.notifications.someone_followed_you)}`
}
case NotificationTypes.FAVOURITE:
case NotificationTypes.REBLOG: {
return this.$t(notificationTypeToI18nTagsMap[notification.type]) + ": " + formatHtml(notification.status.content)
}
case NotificationTypes.MENTION: {
return formatHtml(notification.status.content)
}
}
}
async onNotificationCardClick (notification: mastodonentities.Notification) {
if (!notification.status) {
if (notification.type === NotificationTypes.FOLLOW) {
window.open(notification.account.url, "_blank")
}
} else {
this.isLoadingSingleCard = false
this.isLoading = true
const targetStatus = await prepareRootStatus(notification.status)
this.isLoading = false
this.$emit('updateCurrentCheckStatus', targetStatus)
}
}
shouldShowFollowOperateBtn (notification: mastodonentities.Notification, operateType: string) {
const isFollowingNotification = notification.type === NotificationTypes.FOLLOW
let typeCheckOK = false
if (operateType === this.followOperateBtnTypes.FOLLOW) {
typeCheckOK = this.relationships[notification.account.id] && !this.relationships[notification.account.id].following
} else if (operateType === this.followOperateBtnTypes.UN_FOLLOW) {
typeCheckOK = this.relationships[notification.account.id] && this.relationships[notification.account.id].following
}
return isFollowingNotification && typeCheckOK
}
async onFollowingAccount (id: string) {
this.isLoadingSingleCard = true
this.isLoading = true
await this.followAccountById(id)
this.isLoading = false
}
async onUnFollowingAccount (id: string) {
this.isLoadingSingleCard = true
this.isLoading = true
await this.unFollowAccountById(id)
this.isLoading = false
}
}
export default Card
</script>
<style lang="less" scoped>
.notification-card {
margin: 2px 0;
box-shadow: 0 1px 2px rgba(0,0,0,.2);
border-radius: 3px;
cursor: pointer;
.user-avatar {
cursor: pointer;
}
.user-display-name {
display: inline;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.notification-content {
cursor: pointer;
}
.follow-action {
cursor: pointer;
}
}
</style>
================================================
FILE: src/components/Notifications/index.vue
================================================
<template>
<div class="notification-panel-container base-theme-bg-color" :style="isLoadingTargetStatus ? { overflow: 'hidden' } : null">
<keep-alive>
<div class="notification-list" v-show="!shouldShowTargetStatus">
<mu-load-more loading-text="" @load="loadNotifications(true)" :loading="isLoadingNotifications">
<mu-flex v-if="!hideHeader" class="panel-header" calign-items="center">
<mu-flex justify-content="start" fill>
<mu-sub-header class="secondary-read-text-color">Notifications</mu-sub-header>
</mu-flex>
<mu-flex justify-content="end" fill>
<mu-button icon @click="loadNotifications(false, true)">
<mu-icon class="primary-read-text-color" value="refresh" />
</mu-button>
</mu-flex>
</mu-flex>
<mu-list textline="three-line">
<notification-card :notification="notification" @updateCurrentCheckStatus="onUpdateCurrentCheckStatus"
v-for="(notification, index) in notificationsToShow" :key="index"/>
</mu-list>
</mu-load-more>
</div>
</keep-alive>
<div v-if="shouldShowTargetStatus" class="notification-status-check-area">
<mu-appbar color="secondary">
<mu-button slot="left" icon @click.stop="shouldShowTargetStatus = false">
<mu-icon value="arrow_back" />
</mu-button>
</mu-appbar>
<div class="notification-status-card-container">
<status-card class="status-card-container no-limit-reply-area-height" v-if="currentCheckStatus" :status="currentCheckStatus"/>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Vue, Component, Prop, Watch } from 'vue-property-decorator'
import { State, Action, Mutation } from 'vuex-class'
import { NotificationTypes, UiWidthCheckConstants } from '@/constant'
import StatusCard from '@/components/StatusCard'
import NotificationCard from './Card'
import { mastodonentities } from '@/interface'
import { prepareRootStatus, formatHtml } from "@/util"
@Component({
components: {
'status-card': StatusCard,
'notification-card': NotificationCard
}
})
class Notifications extends Vue {
$progress
$router
$routersInfo
@Prop() hideHeader: boolean
@Action('updateNotifications') updateNotifications
@Mutation('updateNotificationsPanelStatus') updateNotificationsPanelStatus
@State('notifications') notifications: Array<mastodonentities.Notification>
@State('contextMap') contextMap: {
[statusId: string]: {
ancestors: Array<string>
descendants: Array<string>
}
}
@State('appStatus') appStatus
isLoadingNotifications: boolean = false
// todo
isLoadingTargetStatus: boolean = false
shouldShowTargetStatus: boolean = false
currentCheckStatus: mastodonentities.Status = null
@Watch('isLoadingNotifications')
onLoadingNotificationStatusChanged (toValue) {
toValue ? this.$progress.start() : this.$progress.done()
}
@Watch('shouldShowTargetStatus')
onShouldShowTargetStatusChanged (val) {
this.$emit('shouldShowTargetStatusChanged', val)
}
get notificationsToShow () {
const allDescendantsToMute = []
this.appStatus.settings.muteMap.statusList.forEach(statusId => {
if (this.contextMap[statusId]) {
allDescendantsToMute.push(...this.contextMap[statusId].descendants, statusId)
}
})
return this.notifications.filter(notification => {
let toMuteByStatus, toMuteByUser
if (notification.status) toMuteByStatus = allDescendantsToMute.indexOf(notification.status.id) !== -1
if (notification.account) toMuteByUser = this.appStatus.settings.muteMap.userList.indexOf(notification.account.id) !== -1
return !toMuteByStatus && !toMuteByUser
})
}
async loadNotifications (isLoadMore, isFetchMore) {
if (this.shouldShowTargetStatus) return
this.isLoadingNotifications = true
await this.updateNotifications({
isLoadMore,
isFetchMore
})
this.isLoadingNotifications = false
}
onUpdateCurrentCheckStatus (targetStatus: mastodonentities.Status) {
if (this.appStatus.documentWidth < UiWidthCheckConstants.NOTIFICATION_DIALOG_TOGGLE_WIDTH) {
this.updateNotificationsPanelStatus(false)
return this.$router.push({
name: this.$routersInfo.statuses.name,
params: { statusId: targetStatus.id }
})
}
this.currentCheckStatus = targetStatus
this.shouldShowTargetStatus = true
}
}
export default Notifications
</script>
<style lang="less" scoped>
.notification-panel-container {
width: 100%;
height: calc(100vh - 56px) !important;
max-height: 1200px;
position: relative;
.notification-list {
padding: 8px;
height: 100%;
overflow: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
.notification-status-check-area {
height: 100%;
.notification-status-card-container {
padding-top: 8px;
.status-card-container {
height: 100%;
margin-bottom: 40px;
}
}
}
}
</style>
<style lang="less">
.notification-panel-container {
.notification-list {
.mu-item-wrapper.hover {
background-color: inherit !important;
cursor: pointer;
}
.notification-content {
> p { display: inline }
}
.mu-item-sub-title {
p { margin: 0 }
}
.mu-avatar {
margin: 0;
}
}
}
</style>
================================================
FILE: src/components/PostStatusDialog.vue
================================================
<template>
<mu-dialog dialog-class="post-status-dialog-container"
:open.sync="isDialogOpening" overlay-color="rgba(0,0,0,0.12)"
:overlay-opacity="1" @close="onTryCloseDialog" :transition="transition"
:width="dialogWidth" :fullscreen="shouldDialogFullScreen" v-loading="isPostLoading">
<mu-appbar v-if="shouldDialogFullScreen" class="dialog-fullscreen-bar" color="primary">
<mu-button slot="left" icon @click="onTryCloseDialog">
<mu-icon value="close"></mu-icon>
</mu-button>
<mu-button slot="right" flat :disabled="!shouldEnableSubmitButton"
@click="onSubmitNewStatus">
{{$t($i18nTags.statusCard.submit_post)}}
</mu-button>
</mu-appbar>
<div class="dialog-header">
<div class="left-area">
<mu-avatar class="current-user-avatar" slot="avatar" size="40">
<img :src="currentUserAccount.avatar">
</mu-avatar>
<div class="user-and-status-info">
<a class="user-name primary-read-text-color" v-html="currentUserAccount.display_name"></a>
<div class="visibility-row">
<div class="arrow-container">
<svg viewBox="0 0 48 48" height="100%" width="100%">
<path class="header-svg-fill" d="M20 14l10 10-10 10z" />
</svg>
</div>
<div class="visibility-info secondary-theme-text-color" ref="visibilitySelectBtn"
@click="setVisibilitySelectPopOverDisplay(true)">
{{$t(visibility)}}
<mu-icon size="18" class="visibility-icon secondary-read-text-color" :value="getVisibilityDescInfo(visibility).icon"></mu-icon>
</div>
<visibility-select-pop-over :visibility.sync="visibility"
:open.sync="shouldOpenVisibilitySelectPopOver"
:trigger="visibilityTriggerBtn"/>
</div>
</div>
</div>
<div class="right-area">
<div class="card-header-action">
<mu-icon class="header-icon" value="more_vert" />
</div>
</div>
</div>
<section>
<cuckoo-input ref="cuckooInput" @submit="onSubmitNewStatus"
@esc="onTryCloseDialog"
:shouldShowSpoilerTextInputArea="shouldShowSpoilerTextInputArea"
:spoilerText.sync="spoilerTextValue"
:text.sync="textContentValue" :uploadProcesses.sync="uploadProcesses"
:placeholder="$t($i18nTags.statusCard.post_new_status_placeholder)"/>
<div class="bottom-area">
<div class="attachment-select-btn-group">
<mu-button icon @click="onSelectMediaFiles" :disabled="uploadProcesses.length === 4">
<mu-icon class="secondary-read-text-color" value="camera_alt" />
<input ref="fileInput" type="file" @change="onUploadMediaFiles"
accept=".jpg,.jpeg,.png,.gif,.webm,.mp4,.m4v,.mov,image/jpeg,image/png,image/gif,video/webm,video/mp4,video/quicktime"
style="display: none" multiple/>
</mu-button>
<!--<mu-button icon>-->
<!--<mu-icon class="secondary-read-text-color" value="link" />-->
<!--</mu-button>-->
<mu-button v-if="uploadProcesses.length" @click="markMediaAsSensitive = !markMediaAsSensitive"
class="secondary-read-text-color" icon>
<mu-icon :value="markMediaAsSensitive ? 'visibility_off' : 'visibility'" />
</mu-button>
<mu-button @click="shouldShowSpoilerTextInputArea = !shouldShowSpoilerTextInputArea"
class="operate-btn" icon
:class="shouldShowSpoilerTextInputArea ? 'secondary-theme-text-color' : 'secondary-read-text-color'">
<mu-icon class="reply-action-icon" value="add_alert" />
</mu-button>
</div>
<div class="content-length-indicator secondary-read-text-color">
{{textContentValue.length}}/500
</div>
</div>
</section>
<footer v-if="!shouldDialogFullScreen">
<mu-button class="dialog-button secondary-theme-text-color" flat :disabled="!shouldEnableSubmitButton"
@click="onSubmitNewStatus">
{{$t($i18nTags.statusCard.submit_post)}}
</mu-button>
<mu-button class="dialog-button" color="secondary" flat @click="onTryCloseDialog">
{{$t($i18nTags.statusCard.cancel_post)}}
</mu-button>
</footer>
</mu-dialog>
</template>
<script lang="ts">
import { Vue, Component, Prop, Watch } from 'vue-property-decorator'
import { State, Getter, Action } from 'vuex-class'
import { UiWidthCheckConstants, VisibilityTypes } from '@/constant'
import { getVisibilityDescInfo } from '@/util'
import VisibilitySelectPopOver from '@/components/VisibilitySelectPopOver'
import Input from '@/components/Input'
import { mastodonentities } from "../interface";
const maxUploadLength = 4
class InjectDragAndDropEvents {
private dialogComponent
private beingDragOver: boolean = false
constructor (dialogComponent: PostStatusDialog) {
this.dialogComponent = dialogComponent
this.dialogComponent.$el.addEventListener('dragenter', this.onDragOver.bind(this))
}
private insertDragOverLayer () {
const layer = document.createElement('div')
layer.className = 'mu-loading-wrap drag-over-layer'
layer.innerText = this.dialogComponent.$t(this.dialogComponent.$i18nTags.common.drag_and_drop_to_upload)
this.dialogComponent.$el.appendChild(layer)
layer.addEventListener('dragover', e => e.preventDefault())
layer.addEventListener('dragleave', this.onDragLeave.bind(this))
layer.addEventListener('drop', this.onDrop.bind(this))
}
private removeDragOverLayer () {
this.dialogComponent.$el.removeChild(this.dialogComponent.$el.querySelector('.drag-over-layer'))
}
private onDragOver (e: DragEvent) {
e.preventDefault()
if (!this.beingDragOver) {
this.beingDragOver = true
this.insertDragOverLayer()
}
}
private onDragLeave (e: DragEvent) {
e.preventDefault()
if (this.beingDragOver) {
this.beingDragOver = false
this.removeDragOverLayer()
}
}
private onDrop (e: DragEvent) {
e.preventDefault()
this.beingDragOver = false
this.removeDragOverLayer()
const filesToUpload = Array.from(e.dataTransfer.files)
.splice(0, maxUploadLength - this.dialogComponent.uploadProcesses.length)
if (filesToUpload.length === 0) return
filesToUpload.forEach(file => {
this.dialogComponent.uploadProcesses.push({
file, hasStartedUpload: false, uploadResult: null
})
})
}
}
@Component({
components: {
'cuckoo-input': Input,
'visibility-select-pop-over': VisibilitySelectPopOver
}
})
class PostStatusDialog extends Vue {
$refs: {
cuckooInput: Input
textArea: HTMLTextAreaElement
fileInput: HTMLInputElement
visibilitySelectBtn: HTMLDivElement
}
$confirm
$t
$i18nTags
$toast
getVisibilityDescInfo = getVisibilityDescInfo
isConfirmDialogShowing: boolean = false
postPrivacy = null
get visibility () {
return this.postPrivacy || this.appStatus.settings.postPrivacy
}
set visibility (val) {
this.postPrivacy = val
}
postMediaAsSensitiveMode: boolean = null
get markMediaAsSensitive () {
return typeof this.postMediaAsSensitiveMode === 'boolean' ? this.postMediaAsSensitiveMode :
this.appStatus.settings.postMediaAsSensitiveMode
}
set markMediaAsSensitive (val) {
this.postMediaAsSensitiveMode = val
}
visibilityTriggerBtn: HTMLDivElement = null
shouldOpenVisibilitySelectPopOver = false
isPostLoading: boolean = false
uploadProcesses: Array<{
file: File,
hasStartedUpload: boolean,
uploadResult: mastodonentities.Attachment
}> = []
textContentValue: string = ''
shouldShowSpoilerTextInputArea: boolean = null
spoilerTextValue: string = ''
@Prop() open: boolean
@Prop() close: Function
@State('appStatus') appStatus
@State('currentUserAccount') currentUserAccount
@Action('postStatus') postStatus
@Getter('shouldDialogFullScreen') shouldDialogFullScreen
get isDialogOpening () {
return this.open
}
set isDialogOpening (val) {}
get shouldEnableSubmitButton () {
const isInUploadProcess = this.uploadProcesses.every(i => !i.uploadResult)
return this.uploadProcesses.length ? !isInUploadProcess : this.textContentValue
}
@Watch('isDialogOpening')
onDialogOpenChanged (isOpening) {
if (isOpening) {
this.$nextTick(() => {
this.visibilityTriggerBtn = this.$refs.visibilitySelectBtn
this.$refs.cuckooInput.focus()
new InjectDragAndDropEvents(this)
})
} else {
this.setVisibilitySelectPopOverDisplay(false)
}
}
get dialogWidth () {
return this.shouldDialogFullScreen ? null : UiWidthCheckConstants.POST_STATUS_DIALOG_TOGGLE_WIDTH
}
get transition () {
return this.shouldDialogFullScreen ? 'slide-bottom' : 'slide-top'
}
async onTryCloseDialog () {
if (this.isConfirmDialogShowing) return
if (this.textContentValue || this.spoilerTextValue || this.uploadProcesses.length) {
this.isConfirmDialogShowing = true
const doCloseDialog = (await this.$confirm(this.$t(this.$i18nTags.postStatusDialog.do_discard_message_confirm), {
okLabel: this.$t(this.$i18nTags.postStatusDialog.do_discard_message),
cancelLabel: this.$t(this.$i18nTags.postStatusDialog.do_keep_message),
})).result
if (doCloseDialog) {
this.closeDialog()
} else {
this.$refs.cuckooInput.focus()
}
this.isConfirmDialogShowing = false
} else {
this.closeDialog()
}
}
async onSubmitNewStatus () {
if (!this.shouldEnableSubmitButton) return
if (this.textContentValue.length > 500) {
return this.$toast.error(this.$t(this.$i18nTags.postStatusDialog.text_character_limit_exceed))
}
const formData = {
status: this.textContentValue,
visibility: this.visibility,
spoilerText: this.shouldShowSpoilerTextInputArea ? this.spoilerTextValue : '',
sensitive: this.postMediaAsSensitiveMode,
mediaIds: this.uploadProcesses.map(info => info.uploadResult.id)
}
this.isPostLoading = true
await this.postStatus({ formData })
this.isPostLoading = false
// clear data and close dialog
this.closeDialog()
}
onSelectMediaFiles () {
this.$refs.fileInput.click()
}
onUploadMediaFiles () {
Array.from(this.$refs.fileInput.files)
.splice(0, maxUploadLength - this.uploadProcesses.length)
.forEach(file => {
this.uploadProcesses.push({
file, hasStartedUpload: false, uploadResult: null
})
})
this.$refs.fileInput.value = ''
}
setVisibilitySelectPopOverDisplay (open: boolean) {
this.shouldOpenVisibilitySelectPopOver = open
}
closeDialog () {
this.textContentValue = ''
this.$refs.fileInput.value = ''
this.spoilerTextValue = ''
this.shouldShowSpoilerTextInputArea = false
this.uploadProcesses = []
this.$emit('update:open', false)
}
}
export default PostStatusDialog
</script>
<style lang="less" scoped>
.post-status-dialog-container {
.dialog-header {
line-height: 1;
display: flex;
justify-content: space-between;
padding: 16px 4px 8px 16px;
.left-area {
display: flex;
align-items: center;
.current-user-avatar {
margin-right: 8px;
cursor: pointer;
}
.user-and-status-info {
display: flex;
align-items: center;
.user-name {
cursor: pointer;
font-size: 15px;
}
.visibility-row {
display: flex;
align-items: center;
.arrow-container {
width: 18px;
height: 18px;
}
.visibility-info {
cursor: pointer;
display: flex;
align-items: center;
.visibility-icon {
margin-left: 4px;
}
}
}
}
}
.right-area {
display: flex;
align-items: center;
width: 48px;
height: 48px;
.card-header-action {
.header-icon {
cursor: pointer;
font-size: 18px;
margin-left: 10px;
}
}
}
}
section {
@media (max-width: 530px) {
height: calc(100% - 56px - 72px);
display: flex;
flex-direction: column;
justify-content: space-between;
.auto-size-text-area {
max-height: unset !important;
flex-grow: 1;
}
}
.auto-size-text-area {
height: 187px;
padding: 0 16px;
max-height: 373px;
}
.bottom-area {
display: flex;
justify-content: space-between;
.content-length-indicator {
line-height: 48px;
font-size: 16px;
margin-right: 20px;
}
}
}
footer {
display: flex;
align-items: center;
flex-direction: row-reverse;
height: 57px;
.dialog-button {
margin-right: 16px;
}
}
}
</style>
<style lang="less">
.post-status-dialog-container {
border-radius: 4px;
.mu-dialog-body {
padding: 0;
}
section {
.cuckoo-input-container {
margin: 0 16px;
width: auto;
}
@media (max-width: 530px) {
.auto-size-text-area {
max-height: unset !important;
flex-grow: 1;
}
}
.auto-size-text-area {
height: 187px;
max-height: 373px;
}
}
}
.mu-item-wrapper {
&:hover {
}
}
</style>
================================================
FILE: src/components/StatusCard/CardHeader.vue
================================================
<template>
<mu-card-header class="mu-card-header" ref="cardHeader"
@mouseover="shouldShowHeaderActionButtonGroup = true"
@mouseout="shouldShowHeaderActionButtonGroup = false">
<div class="left-area" :style="leftAreaStyle">
<mu-avatar @click="onCheckUserAccountPage" class="status-account-avatar" slot="avatar" size="34">
<img :src="status.account.avatar">
</mu-avatar>
<div class="user-and-status-info">
<a @click="onCheckUserAccountPage" class="user-name primary-read-text-color">
<span class="display-name" v-html="getAccountDisplayName(status.account)"></span>
<span class="at-name secondary-read-text-color">@{{getAccountAtName(status.account)}}</span>
</a>
<div ref="visibilityInfo" class="visibility-row secondary-read-text-color">
<div class="arrow-container">
<svg viewBox="0 0 48 48" height="100%" width="100%">
<path class="header-svg-fill" d="M20 14l10 10-10 10z" />
</svg>
</div>
<div class="visibility-info secondary-read-text-color">{{$t(status.visibility)}}</div>
</div>
</div>
</div>
<div class="right-area" ref="rightArea" v-if="isOAuthUser">
<span v-show="!shouldOpenMoreOperationPopOver && !shouldShowHeaderActionButtonGroup" class="status-from-now secondary-read-text-color">{{getFromNowTime(status.created_at)}}</span>
<div v-show="shouldOpenMoreOperationPopOver || shouldShowHeaderActionButtonGroup" class="card-header-action">
<mu-icon class="header-icon secondary-read-text-color" value="open_in_new" @click="onCheckStatusInSinglePage"/>
<mu-icon class="header-icon secondary-read-text-color" value="more_vert" ref="moreOperationTriggerBtn"
@click="shouldOpenMoreOperationPopOver = true"/>
</div>
</div>
<mu-popover v-if="isOAuthUser" cover placement="left-start"
:open.sync="shouldOpenMoreOperationPopOver"
:trigger="moreOperationTriggerBtn">
<mu-list>
<mu-list-item button @click.stop="onMuteStatusByOperateList">
<mu-list-item-title>{{$t($i18nTags.statusCard.mute_status)}}</mu-list-item-title>
</mu-list-item>
<mu-list-item button v-if="currentUserAccount.id !== status.account.id"
@click.stop="onMuteUserByOperateList">
<mu-list-item-title>{{$t($i18nTags.statusCard.mute_user)}}</mu-list-item-title>
</mu-list-item>
<mu-list-item button v-if="currentUserAccount.id === status.account.id"
@click.stop="onDeleteStatusByOperateList">
<mu-list-item-title>{{$t($i18nTags.statusCard.delete_status)}}</mu-list-item-title>
</mu-list-item>
</mu-list>
</mu-popover>
</mu-card-header>
</template>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator'
import { Getter, State, Action } from 'vuex-class'
import * as moment from 'moment'
import { mastodonentities } from '@/interface'
@Component({})
class CardHeader extends Vue {
@Prop() status: mastodonentities.Status
$router
$routersInfo
$confirm
$t
$i18nTags
$refs: {
cardHeader: HTMLDivElement
visibilityInfo: any
rightArea: HTMLDivElement
moreOperationTriggerBtn: any
}
@Getter('getAccountDisplayName') getAccountDisplayName
@Getter('getAccountAtName') getAccountAtName
@Getter('isOAuthUser') isOAuthUser
@State('currentUserAccount') currentUserAccount: mastodonentities.AuthenticatedAccount
@Action('deleteStatus') deleteStatus
shouldShowHeaderActionButtonGroup = false
shouldOpenMoreOperationPopOver = false
moreOperationTriggerBtn: any = null
leftAreaStyle = null
mounted () {
if (this.isOAuthUser) {
this.moreOperationTriggerBtn = this.$refs.moreOperationTriggerBtn
}
this.setLeftAreaStyle()
}
onOpenMoreOperationPopOver () {
this.shouldOpenMoreOperationPopOver = true
}
onCheckUserAccountPage () {
window.open(this.status.account.url, "_blank")
}
onCheckStatusInSinglePage () {
this.$router.push({
name: this.$routersInfo.statuses.name,
params: {
statusId: this.status.id
}
})
}
async onDeleteStatusByOperateList () {
const targetStatusId = this.status.id
const doDeleteStatus = (await this.$confirm(this.$t(this.$i18nTags.statusCard.delete_status_confirm), {
okLabel: this.$t(this.$i18nTags.statusCard.do_delete_status_btn),
cancelLabel: this.$t(this.$i18nTags.statusCard.cancel_delete_status_btn),
})).result
if (doDeleteStatus) {
this.$emit('deleteStatus')
await this.deleteStatus({ statusId: targetStatusId })
}
}
onMuteStatusByOperateList () {
this.$emit('muteStatus', this.status.id)
}
onMuteUserByOperateList () {
this.$emit('muteUser', this.status.account.id)
}
getFromNowTime (createdAt: string) {
return moment(createdAt).fromNow(true)
}
setLeftAreaStyle () {
const headerPadding = 16 * 2
const rightAreaWidth = Math.max(60, this.$refs.rightArea.clientWidth)
this.leftAreaStyle = {
maxWidth: `${this.$refs.cardHeader.clientWidth - headerPadding - rightAreaWidth}px`
}
}
}
export default CardHeader
</script>
<style lang="less" scoped>
.mu-card-header {
line-height: 1;
display: flex;
justify-content: space-between;
.left-area {
display: flex;
align-items: center;
.status-account-avatar {
margin-right: 8px;
cursor: pointer;
flex-shrink: 0;
}
.user-and-status-info {
display: flex;
flex-wrap: wrap;
align-items: center;
max-width: calc(100% - 34px - 8px);
.user-name {
cursor: pointer;
font-size: 15px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 700;
}
.visibility-row {
display: flex;
align-items: center;
.arrow-container {
width: 18px;
height: 18px;
}
.visibility-info {
cursor: pointer;
}
}
}
}
.right-area {
display: flex;
align-items: center;
.status-from-now {
font-size: 13px;
font-weight: 400;
}
.card-header-action {
.header-icon {
cursor: pointer;
font-size: 18px;
margin-left: 10px;
}
}
}
}
</style>
================================================
FILE: src/components/StatusCard/FullActionBar.vue
================================================
<template>
<div class="full-action-bar">
<div class="reply-input-area">
<mu-avatar class="current-user-avatar" slot="avatar" size="24">
<img :src="currentUserAccount.avatar">
</mu-avatar>
<div class="input-container">
<cuckoo-input ref="cuckooInput" @submit="onSubmitReplyContent"
@esc="onTryHideFullReplyActionArea"
:shouldShowSpoilerTextInputArea="shouldShowSpoilerTextInputArea"
:spoilerText.sync="replySpoilerTextValue"
:text.sync="inputValue" :uploadProcesses.sync="uploadProcesses"
:presetAtAccounts="presetAtAccounts"
:placeholder="$t($i18nTags.statusCard.reply_to_main_status)"/>
</div>
</div>
<div class="reply-action-area">
<div class="left-area">
<mu-button @click.stop="onSelectMediaFiles" :disabled="uploadProcesses.length === 4"
class="operate-btn add-image secondary-read-text-color" icon :title="$t($i18nTags.statusCard.add_photos)">
<mu-icon class="reply-action-icon" value="camera_alt" />
<input ref="fileInput" type="file" @change="onUploadMediaFiles"
accept=".jpg,.jpeg,.png,.gif,.webm,.mp4,.m4v,.mov,image/jpeg,image/png,image/gif,video/webm,video/mp4,video/quicktime"
style="display: none" multiple/>
</mu-button>
<mu-button ref="visibilityTriggerBtn" @click.stop="shouldOpenVisibilitySelectPopOver = true" class="operate-btn change-visibility secondary-read-text-color" icon :title="$t($i18nTags.statusCard.change_visibility)">
<mu-icon class="reply-action-icon" :value="getVisibilityDescInfo(visibility).icon" />
</mu-button>
<mu-button v-if="uploadProcesses.length" @click.stop="markMediaAsSensitive = !markMediaAsSensitive"
class="operate-btn secondary-read-text-color" icon>
<mu-icon class="reply-action-icon" :value="markMediaAsSensitive ? 'visibility_off' : 'visibility'" />
</mu-button>
<mu-button @click.stop="shouldShowSpoilerTextInputArea = !shouldShowSpoilerTextInputArea"
class="operate-btn" icon
:class="shouldShowSpoilerTextInputArea ? 'secondary-theme-text-color' : 'secondary-read-text-color'">
<mu-icon class="reply-action-icon" value="add_alert" />
</mu-button>
</div>
<div class="right-area">
<mu-button flat class="operate-btn cancel"
color="secondary" @click.stop="hideFullReplyActionArea">{{$t($i18nTags.statusCard.cancel_post)}}</mu-button>
<mu-button flat class="operate-btn submit secondary-theme-text-color" @click.stop="onSubmitReplyContent"
:disabled="!shouldEnableSubmitButton">{{$t($i18nTags.statusCard.submit_post)}}</mu-button>
</div>
</div>
<visibility-select-pop-over :visibility.sync="visibility"
:open.sync="shouldOpenVisibilitySelectPopOver"
:trigger="visibilityTriggerBtn"/>
</div>
</template>
<script lang="ts">
import { Vue, Component, Prop, Watch } from 'vue-property-decorator'
import { State, Action } from 'vuex-class'
import { VisibilityTypes } from '@/constant'
import { getVisibilityDescInfo } from '@/util'
import VisibilitySelectPopOver from '@/components/VisibilitySelectPopOver'
import Input from '@/components/Input'
import { mastodonentities } from '@/interface'
const maxUploadLength = 4
@Component({
components: {
'cuckoo-input': Input,
'visibility-select-pop-over': VisibilitySelectPopOver
}
})
class FullActionBar extends Vue {
$confirm
$t
$i18nTags
$refs: {
cuckooInput: Input
replayTextInput: HTMLTextAreaElement
fileInput: HTMLInputElement
visibilityTriggerBtn: any
}
@Prop() status
@Prop() value
@Prop() replySpoilerText
@Prop() currentReplyToStatus
@Prop() descendantStatusList: Array<mastodonentities.Status>
@Prop() droppedFiles: Array<File>
@State('currentUserAccount') currentUserAccount
@State('appStatus') appStatus
@Action('postStatus') postStatus
isConfirmDialogShowing: boolean = false
postPrivacy_ = null
get postPrivacy () {
if (this.currentReplyToStatus.visibility === VisibilityTypes.DIRECT && !this.postPrivacy_) {
return VisibilityTypes.DIRECT
}
return this.postPrivacy_
}
set postPrivacy (val) {
this.postPrivacy_ = val
}
postMediaAsSensitiveMode: boolean = null
shouldShowSpoilerTextInputArea: boolean = null
get visibility () {
return this.postPrivacy || this.appStatus.settings.postPrivacy
}
set visibility (val) {
this.postPrivacy = val
}
get markMediaAsSensitive () {
return typeof this.postMediaAsSensitiveMode === 'boolean' ? this.postMediaAsSensitiveMode :
this.appStatus.settings.postMediaAsSensitiveMode
}
set markMediaAsSensitive (val) {
this.postMediaAsSensitiveMode = val
}
shouldOpenVisibilitySelectPopOver = false
uploadProcesses: Array<{
file: File,
hasStartedUpload: boolean,
uploadResult: mastodonentities.Attachment
}> = []
visibilityTriggerBtn: any = null
getVisibilityDescInfo = getVisibilityDescInfo
get shouldEnableSubmitButton () {
const isInUploadProcess = this.uploadProcesses.every(i => !i.uploadResult)
return this.uploadProcesses.length ? !isInUploadProcess : this.inputValue
}
get inputValue () {
return this.value
}
set inputValue (val) {
this.$emit('update:value', val)
}
get replySpoilerTextValue () {
return this.replySpoilerText
}
set replySpoilerTextValue (val) {
this.$emit('update:replySpoilerText', val)
}
get presetAtAccounts (): Array<mastodonentities.Account> {
const result: Array<mastodonentities.Account> = [];
[this.status, ...this.descendantStatusList].forEach(status => {
if (!result.find(acc => acc.id === status.account.id)) {
result.push(status.account)
}
})
return result
}
mounted () {
this.visibilityTriggerBtn = this.$refs.visibilityTriggerBtn.$el
this.$refs.cuckooInput.focus()
this.$refs.cuckooInput.updateSize()
this.prepareDroppedFiles()
}
@Watch('droppedFiles')
onDropFiles () {
this.prepareDroppedFiles()
}
async onSubmitReplyContent () {
if (!this.shouldEnableSubmitButton) return
const currentReplyToStatus = this.currentReplyToStatus
this.$emit('loadingStart')
await this.postStatus({
mainStatusId: this.status.id,
formData: {
status: this.value,
spoilerText: this.shouldShowSpoilerTextInputArea ? this.replySpoilerText : '',
inReplyToId: currentReplyToStatus.id,
visibility: this.visibility,
sensitive: this.postMediaAsSensitiveMode,
mediaIds: this.uploadProcesses.map(info => info.uploadResult.id)
}
})
this.$emit('loadingEnd')
this.$emit('replySuccess')
this.hideFullReplyActionArea()
}
onSelectMediaFiles () {
this.$refs.fileInput.click()
}
onUploadMediaFiles () {
Array.from(this.$refs.fileInput.files)
.splice(0, maxUploadLength - this.uploadProcesses.length)
.forEach(file => {
this.uploadProcesses.push({
file, hasStartedUpload: false, uploadResult: null
})
})
this.$refs.fileInput.value = ''
}
prepareDroppedFiles () {
if (!this.droppedFiles || !this.droppedFiles.length) return
const filesToUpload = [...this.droppedFiles].splice(0, maxUploadLength - this.uploadProcesses.length)
// todo show notification toast
if (!filesToUpload.length) return
filesToUpload.forEach(file => {
this.uploadProcesses.push({
file, hasStartedUpload: false, uploadResult: null
})
})
}
hideFullReplyActionArea () {
this.uploadProcesses = []
this.shouldShowSpoilerTextInputArea = false
this.$emit('hide')
}
async onTryHideFullReplyActionArea () {
if (this.isConfirmDialogShowing) return
if (this.inputValue || this.replySpoilerTextValue || this.uploadProcesses.length) {
this.isConfirmDialogShowing = true
const doHideFullReplyActionArea = (await this.$confirm(this.$t(this.$i18nTags.postStatusDialog.do_discard_message_confirm), {
okLabel: this.$t(this.$i18nTags.postStatusDialog.do_discard_message),
cancelLabel: this.$t(this.$i18nTags.postStatusDialog.do_keep_message),
})).result
if (doHideFullReplyActionArea) {
this.hideFullReplyActionArea()
} else {
this.$refs.cuckooInput.focus()
}
this.isConfirmDialogShowing = false
} else {
this.hideFullReplyActionArea()
}
}
}
export default FullActionBar
</script>
<style lang="less" scoped>
.full-action-bar {
padding: 12px 16px 0 16px;
.reply-input-area {
display: flex;
.current-user-avatar {
margin-top: 6px;
}
.input-container {
width: 100%;
display: flex;
align-items: center;
flex-grow: 1;
margin-left: 16px;
padding: 9px 12px 8px 0;
}
}
.reply-action-area {
display: flex;
align-items: center;
justify-content: space-between;
height: 48px;
margin: 0 -12px;
.left-area {
display: flex;
.operate-btn {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
.reply-action-icon {
font-size: 18px;
}
}
}
.right-area {
display: flex;
}
}
}
</style>
<style lang="less">
.full-action-bar {
.auto-size-text-area {
height: 18px;
}
}
</style>
================================================
FILE: src/components/StatusCard/FullReplyListItem.vue
================================================
<template>
<div class="full-reply-list-item" v-loading="isListItemLoading" @mouseover="onItemMouseOver" @mouseout="onItemMouseOut">
<div class="left-area">
<mu-avatar @click="onCheckUserAccountPage" class="status-replier-avatar" slot="avatar" size="34">
<img :src="status.account.avatar">
</mu-avatar>
</div>
<div class="content-area" ref="contentArea">
<div class="content-header">
<div class="reply-user-display-name">
<a @click="onCheckUserAccountPage" class="primary-read-text-color">
<span class="display-name" v-html="status.account.display_name"></span>
<span class="at-name secondary-read-text-color">@{{getAccountAtName(status.account)}}</span>
</a>
<span v-if="status.favourites_count > 0"
class="reply-favorites-count"
:class="[ status.favourited ? 'primary-theme-text-color' : 'secondary-read-text-color' ]">
+{{status.favourites_count}}
</span>
</div>
<div class="operation-area" ref="operationArea" :style="operationAreaStyle">
<span v-show="!shouldOpenMoreOperationPopOver && !shouldShowMoreOperationTriggerBtn" class="reply-from-now secondary-read-text-color">{{getFromNowTime()}}</span>
<mu-button v-show="shouldOpenMoreOperationPopOver || shouldShowMoreOperationTriggerBtn" icon style="width: 16px; height: 16px" @click="onOpenMoreOperationPopOver">
<mu-icon class="header-icon secondary-read-text-color" value="more_vert"/>
</mu-button>
</div>
</div>
<div class="spoiler-text-area primary-read-text-color" v-if="status.spoiler_text">
<span v-html="status.spoiler_text"/>
<mu-button flat small class="secondary-theme-text-color" :style="{ minWidth: 'unset' }"
@click="shouldShowContentWhileSpoilerExists = !shouldShowContentWhileSpoilerExists">
{{ $t(shouldShowContentWhileSpoilerExists ? $i18nTags.statusCard.hide_content : $i18nTags.statusCard.show_content) }}
</mu-button>
</div>
<mu-card-text class="status-content full-reply-status-content"
v-show="(status.spoiler_text ? shouldShowContentWhileSpoilerExists : true)"
v-html="status.content"></mu-card-text>
<div v-if="neteaseMusicLink" class="netease-music-panel">
<iframe class="netease-music-iframe" frameborder="no" border="0"
marginwidth="0" marginheight="0" height=86
:src="neteaseMusicLink"></iframe>
</div>
<div v-if="youtubeVideoLink" class="youtube-video-panel">
<iframe class="youtube-video-iframe"
:height="youtubeVideoIFrameHeight"
:src="youtubeVideoLink"
frameborder="0"
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen></iframe>
</div>
<div v-if="!status.reblog && hasLinkCardInfo" class="full-reply-link-preview-area">
<link-preview-panel :cardInfo="cardMap[status.id]"/>
</div>
<div class="full-reply-attachment-area">
<media-panel :mediaList="status.media_attachments" :pixivCards="status.pixiv_cards" :sensitive="status.sensitive"/>
</div>
<div class="reply-action-list">
<a class="reply-button secondary-theme-text-color"
@click="onReplyToStatus">{{$t($i18nTags.statusCard.reply_to_replier)}}</a>
<div class="plus-one-button secondary-theme-text-color"
@click="onFavoriteButtonClick"
:class="{ 'primary-theme-bg-color': status.favourited }">
<a>+1</a>
</div>
<div class="reshare-button secondary-theme-text-color"
@click="onReBlogButtonClick"
:class="{ 'primary-theme-bg-color': status.reblogged }">
<mu-icon class="share-icon" value="share" />
</div>
</div>
</div>
<mu-popover cover placement="left-start"
:open.sync="shouldOpenMoreOperationPopOver"
:trigger="moreOperationTriggerBtn">
<mu-list>
<mu-list-item button @click.stop="onMuteStatus">
<mu-list-item-title>{{$t($i18nTags.statusCard.mute_status)}}</mu-list-item-title>
</mu-list-item>
<mu-list-item button v-if="currentUserAccount.id !== status.account.id"
@click.stop="onMuteUser">
<mu-list-item-title>{{$t($i18nTags.statusCard.mute_user)}}</mu-list-item-title>
</mu-list-item>
<mu-list-item button v-if="currentUserAccount.id === status.account.id"
@click.stop="onDeleteStatus">
<mu-list-item-title>{{$t($i18nTags.statusCard.delete_status)}}</mu-list-item-title>
</mu-list-item>
</mu-list>
</mu-popover>
</div>
</template>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator'
import { Getter, Action, State } from 'vuex-class'
import * as moment from 'moment'
import { mastodonentities } from "@/interface"
import MediaPanel from './MediaPanel'
import LinkPreviewPanel from './LinkPreviewPanel'
import {getNetEaseMusicFrameLinkFromContentLink, getYoutubeVideoFrameLinkFromContentLink } from '@/util'
import * as $ from "jquery"
@Component({
components: {
'media-panel': MediaPanel,
'link-preview-panel': LinkPreviewPanel,
}
})
class FullReplyListItem extends Vue {
$refs: {
operationArea: HTMLDivElement
contentArea: HTMLDivElement
}
$confirm
$t
$i18nTags
@Prop() status: mastodonentities.Status
@State('cardMap') cardMap
@State('currentUserAccount') currentUserAccount: mastodonentities.AuthenticatedAccount
@Action('updateFavouriteStatusById') updateFavouriteStatusById
@Action('updateReblogStatusById') updateReblogStatusById
@Action('deleteStatus') deleteStatus
@Getter('getAccountAtName') getAccountAtName
isListItemLoading: boolean = false
shouldShowMoreOperationTriggerBtn: boolean = false
shouldOpenMoreOperationPopOver: boolean = false
moreOperationTriggerBtn = null
shouldShowContentWhileSpoilerExists: boolean = false
operationAreaStyle = null
youtubeVideoIFrameHeight = 0
get hasLinkCardInfo () {
return this.cardMap[this.status.id]
&& (Object.keys(this.cardMap[this.status.id]).length !== 0)
&& this.cardMap[this.status.id].type === 'link'
}
get contentLinkList () {
return [...$(this.status.content).find('a')].map(a => {
return a.getAttribute('href')
})
}
get neteaseMusicLink () {
return this.contentLinkList.map(link => {
return getNetEaseMusicFrameLinkFromContentLink(link)
}).filter(l => l)[0]
}
get youtubeVideoLink () {
return this.contentLinkList.map(link => {
return getYoutubeVideoFrameLinkFromContentLink(link)
}).filter(l => l)[0]
}
mounted () {
this.operationAreaStyle = {
// todo 2 is magic number
width: `${this.$refs.operationArea.clientWidth + 2}px`
}
this.youtubeVideoIFrameHeight = this.$refs.contentArea.clientWidth * 315 / 560
}
getFromNowTime () {
return moment(this.status.created_at).fromNow(true)
}
onFavoriteButtonClick () {
this.updateFavouriteStatusById({
favourited: !this.status.favourited,
mainStatusId: this.status.id,
targetStatusId: this.status.id
})
}
onReBlogButtonClick () {
this.updateReblogStatusById({
reblogged: !this.status.reblogged,
mainStatusId: this.status.id,
targetStatusId: this.status.id
})
}
onReplyToStatus () {
this.$emit('reply', this.status)
}
onOpenMoreOperationPopOver (e) {
this.moreOperationTriggerBtn = e.target
this.shouldOpenMoreOperationPopOver = true
}
onCheckUserAccountPage () {
window.open(this.status.account.url, "_blank")
}
async onDeleteStatus () {
const doDeleteStatus = (await this.$confirm(this.$t(this.$i18nTags.statusCard.delete_status_confirm), {
okLabel: this.$t(this.$i18nTags.statusCard.do_delete_status_btn),
cancelLabel: this.$t(this.$i18nTags.statusCard.cancel_delete_status_btn),
})).result
if (doDeleteStatus) {
this.isListItemLoading = true
await this.deleteStatus({ statusId: this.status.id })
}
}
onMuteStatus () {
this.$emit('muteStatus', this.status.id)
}
async onMuteUser () {
this.$emit('muteUser', this.status.account.id)
}
onItemMouseOver () {
this.shouldShowMoreOperationTriggerBtn = true
}
onItemMouseOut () {
this.shouldShowMoreOperationTriggerBtn = false
}
}
export default FullReplyListItem
</script>
<style lang="less" scoped>
.full-reply-list-item {
position: relative;
display: flex;
padding: 12px 16px;
.status-replier-avatar {
cursor: pointer;
}
.content-area {
flex-grow: 1;
margin: 0 10px 0 16px;
display: flex;
flex-direction: column;
width: 0;
.content-header {
display: flex;
justify-content: space-between;
min-height: 26px;
line-height: 26px;
.reply-user-display-name {
display: flex;
align-items: center;
> a {
margin: 0;
font-size: 15px;
font-weight: 500;
text-overflow: ellipsis;
overflow: hidden;
cursor: pointer;
}
.reply-favorites-count {
font-size: 13px;
font-weight: 500;
margin-left: 8px;
}
}
.operation-area {
display: flex;
flex-direction: row-reverse;
.reply-from-now {
font-size: 13px;
white-space: nowrap;
}
}
}
.netease-music-panel {
margin-left: -10px;
margin-right: -20px;
}
.full-reply-status-content {
padding: 0;
}
.full-reply-link-preview-area {
margin: 12px 0 0 4px;
}
.reply-action-list {
display: flex;
align-items: center;
margin-top: 6px;
.common-style-mixin() {
cursor: pointer;
font-size: 13px;
margin: 0 8px;
}
.reply-button {
.common-style-mixin();
margin-left: 0;
}
.plus-one-button {
.common-style-mixin();
width: 24px;
height: 24px;
line-height: 24px;
text-align: center;
border-radius: 50%;
}
.reshare-button {
.common-style-mixin();
line-height: 1;
width: 24px;
height: 24px;
text-align: center;
border-radius: 50%;
.share-icon {
font-size: 16px;
line-height: 24px;
}
}
}
}
}
</style>
================================================
FILE: src/components/StatusCard/LinkPreviewPanel.vue
================================================
<template>
<div class="link-preview-panel-container" @click="onLinkPreviewPanelClick">
<div class="content-area">
<img v-if="cardInfo.image" class="preview-image" :src="cardInfo.image" :alt="cardInfo.title"/>
<div class="text-area">
<div class="link-preview-text primary-read-text-color">
{{cardInfo.title}}
</div>
<div class="link-preview-text link-preview-description primary-read-text-color">
{{cardInfo.description}}
</div>
<div class="link-preview-text link-preview-url secondary-read-text-color">
{{cardInfo.url}}
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator'
import {} from 'vuex-class'
import { mastodonentities } from '@/interface'
@Component({})
class LinkPreviewPanel extends Vue {
@Prop() cardInfo: mastodonentities.Card
onLinkPreviewPanelClick () {
window.open(this.cardInfo.url, "_blank")
}
}
export default LinkPreviewPanel
</script>
<style lang="less" scoped>
.link-preview-panel-container {
cursor: pointer;
.content-area {
display: flex;
align-items: center;
overflow: hidden;
.preview-image {
width: 110px;
height: 110px;
margin-right: 16px;
flex-shrink: 0;
object-fit: cover;
}
.text-area {
.link-preview-text {
font-size: 20px;
font-weight: 300;
margin-bottom: 8px;
line-height: 24px;
overflow: hidden;
text-overflow: ellipsis;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
display: -webkit-box;
max-height: 48px;
}
.link-preview-description {
font-size: 16px;
}
.link-preview-url {
font-size: 12px;
}
}
}
}
</style>
<style lang="less">
.full-reply-link-preview-area {
.preview-image {
width: 72px !important;
height: 72px !important;
margin-right: 12px !important;
}
.link-preview-text {
font-size: 16px !important;
margin-top: 8px !important;
margin-bottom: 8px !important;
line-height: 18px !important;
max-height: 36px !important;
}
.link-preview-description {
font-size: 12px !important;
}
.link-preview-url {
font-size: 8px !important;
}
}
</style>
================================================
FILE: src/components/StatusCard/MediaPanel.vue
================================================
<template>
<div class="media-panel-container" v-if="combinedMediaList.length > 0">
<div class="media-area" ref="mediaArea" :style="mediaAreaScrollStyle"
:class="{ 'single-media-area': combinedMediaList.length === 1 }">
<place-holder-media-item class="media-item" v-for="(media, index) in combinedMediaList" :key="index"
@click.native="onMediaItemClick(index)" :holderStyle="getMediaSizeStyle(index)"
:url="media.url" :mediaType="media.type"
:shouldShowSensitiveCover.sync="shouldShowSensitiveCover"/>
<div class="sensitive-alert-cover" v-show="shouldShowSensitiveCover" @click="shouldShowSensitiveCover = false">
<p v-html="$t($i18nTags.statusCard.sensitive_media_alert)"></p>
</div>
</div>
<mu-dialog class="light-box" transition="fade" ref="lightBox"
@click.native.stop="onLightBoxClick"
:open.sync="shouldShowLightBox" :overlay-opacity="0.7">
<mu-icon class="close-icon" value="close" @click="shouldShowLightBox = false"/>
<mu-carousel :cycle="false" :active="lightBoxActiveIndex" transition="fade"
@click.native.stop="onCarouselBackgroundClick"
:hide-indicators="(combinedMediaList.length === 1) || !shouldShowLightBoxControlBtn"
:hide-controls="(combinedMediaList.length === 1) || !shouldShowLightBoxControlBtn">
<mu-carousel-item v-for="(mediaInfo, index) in combinedMediaList" :key="index">
<div class="light-box-item" @click.stop="onLightBoxMediaItemClick">
<img v-if="mediaInfo.type === mediaTypes.IMAGE" :src="mediaInfo.url"/>
<video v-if="mediaInfo.type === mediaTypes.GIFV || mediaInfo.type === mediaTypes.VIDEO"
controls :loop="mediaInfo.type === mediaTypes.GIFV" :src="mediaInfo.url"/>
</div>
</mu-carousel-item>
</mu-carousel>
</mu-dialog>
</div>
</template>
<script lang="ts">
import { Vue, Component, Prop, Watch } from 'vue-property-decorator'
import { State } from 'vuex-class'
import { AttachmentTypes, StatusCardTypes } from '@/constant'
import { documentGlobalEventBus } from '@/util'
import { mastodonentities } from '@/interface'
import PlaceHolderMediaItem from './PlaceHolderMediaItem'
import ImageMeta = mastodonentities.ImageMeta
import GifvMeta = mastodonentities.GifvMeta
let mediaPanelKeyDownEventListener
@Component({
components: {
'place-holder-media-item': PlaceHolderMediaItem
}
})
class MediaPanel extends Vue {
$refs: {
mediaArea: HTMLDivElement
mediaPanelContainer: HTMLDivElement
lightBox: {
$el: HTMLDivElement
}
}
@Prop() mediaList?: Array<mastodonentities.Attachment>
@Prop({ default: () => [] }) pixivCards?: Array<{ url: string, image_url: string }>
@Prop({ default: () => {} }) cardInfo: mastodonentities.Card
@Prop() sensitive?: boolean
@State('appStatus') appStatus
manuallyShowSensitiveCover: boolean = null
isMounted: boolean = false
onLightBoxClick () { }
get fixedPixivCards () {
if (this.mediaList.length) {
return []
}
return this.pixivCards
}
get fixedCardInfo () {
if (this.mediaList.length ||
this.fixedPixivCards.length) {
return null
}
if (!this.cardInfo || (this.cardInfo.type !== StatusCardTypes.PHOTO)) {
return null
}
return this.cardInfo
}
mounted () {
this.isMounted = true
}
get mediaAreaWidth () {
if (!this.isMounted) return null
return this.$refs.mediaArea.clientWidth
}
get mediaAreaScrollStyle () {
if (this.shouldShowSensitiveCover) {
return {
overflow: 'hidden'
}
} else {
return null
}
}
//
getMediaSizeStyle (mediaIndex: number) {
if (!this.isMounted) return {}
if (this.combinedMediaList.length === 1) {
let aspect: number = 1
// for normal media list
if (this.mediaList.length === 1) {
if (!this.mediaList[0].meta) return {}
const mediaType = this.mediaList[0].type
if (mediaType === AttachmentTypes.IMAGE) {
aspect = (this.mediaList[0].meta as ImageMeta).original.aspect
}
else if (mediaType === AttachmentTypes.VIDEO || mediaType === AttachmentTypes.GIFV) {
const originInfo = (this.mediaList[0].meta as GifvMeta).original
aspect = originInfo.width / originInfo.height
}
}
// for pixiv cards and photo type card info
if (this.fixedPixivCards.length === 1) {
// pixiv cards media size is 1050 * 550 by now
aspect = 1050 / 550
}
if (this.fixedCardInfo) {
aspect = this.fixedCardInfo.width / this.fixedCardInfo.height
}
return { height: `${this.mediaAreaWidth / aspect}px` }
} else if (this.combinedMediaList.length > 1) {
if (!this.mediaList[mediaIndex].meta) return {}
// multi media's height was static by now
const height = 212
return { width: `${height * (this.mediaList[mediaIndex].meta as ImageMeta).original.aspect}px` }
}
return {}
}
get shouldShowSensitiveCover () {
if (typeof this.manuallyShowSensitiveCover === 'boolean') {
return this.manuallyShowSensitiveCover
}
if (this.appStatus.settings.showSensitiveContentMode) return false
return this.sensitive
}
set shouldShowSensitiveCover (val) {
this.manuallyShowSensitiveCover = val
this.$refs.mediaArea.scrollTo(0, 0)
}
get combinedMediaList () {
const mediaListPart = this.mediaList.map(item => {
const url = item.url || item.remote_url
let type: string = item.type
if (type === AttachmentTypes.UNKNOWN) {
type = url.endsWith('.mp4') ? AttachmentTypes.GIFV : AttachmentTypes.IMAGE
}
return { url, type, previewUrl: item.preview_url }
})
const pixivCardsPart = this.fixedPixivCards.map(item => {
return { url: item.image_url, type: this.mediaTypes.IMAGE }
})
const photoCardPart = []
if (this.fixedCardInfo) {
photoCardPart.push({
url: this.fixedCardInfo.image,
type: this.mediaTypes.IMAGE
})
}
return [...mediaListPart, ...pixivCardsPart, ...photoCardPart]
}
get mediaAreaClass () {
const mediaAreaClassList = [
'one-media', 'two-medias', 'three-medias', 'four-medias'
]
return mediaAreaClassList[this.combinedMediaList.length - 1]
}
mediaTypes = AttachmentTypes
shouldShowLightBox: boolean = false
shouldShowLightBoxControlBtn: boolean = true
lightBoxActiveIndex: number = 0
@Watch('$route')
onRouteChanged () {
this.shouldShowLightBox = false
}
@Watch('shouldShowLightBox')
onLightBoxStatusChanged () {
if (this.shouldShowLightBox) {
mediaPanelKeyDownEventListener = e => this.onMediaPanelKeyDown(e)
documentGlobalEventBus.on('keydown', mediaPanelKeyDownEventListener)
} else {
documentGlobalEventBus.off('keydown', mediaPanelKeyDownEventListener)
}
}
onMediaItemClick (mediaItemIndex: number) {
this.shouldShowLightBox = true
this.lightBoxActiveIndex = mediaItemIndex
}
onMediaPanelKeyDown (e) {
e.stopPropagation()
e.preventDefault()
switch (e.key) {
case 'h': {
if (this.lightBoxActiveIndex === 0) {
this.lightBoxActiveIndex = this.combinedMediaList.length - 1
} else {
this.lightBoxActiveIndex --
}
break
}
case 'l': {
if (this.lightBoxActiveIndex === this.combinedMediaList.length - 1) {
this.lightBoxActiveIndex = 0
} else {
this.lightBoxActiveIndex ++
}
break
}
}
}
onCarouselBackgroundClick (e) {
if (e.target.className === 'mu-carousel-item') {
this.shouldShowLightBox = false
}
}
onLightBoxMediaItemClick () {
this.shouldShowLightBoxControlBtn = !this.shouldShowLightBoxControlBtn
}
}
export default MediaPanel
</script>
<style lang="less" scoped>
.media-panel-container {
.media-area {
position: relative;
&.single-media-area {
width: 100%;
height: auto;
padding-left: 0;
}
.sensitive-alert-cover {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
background: rgba(0,0,0,0.2);
color: #fff;
font-weight: 700;
align-items: center;
justify-content: center;
cursor: pointer;
}
}
}
</style>
<style lang="less">
.media-panel-container {
img, video {
display: block;
cursor: zoom-in;
filter: blur(0);
&.sensitive-hide {
filter: blur(20px);
}
-webkit-transition: filter 200ms;
-moz-transition: filter 200ms;
-ms-transition: filter 200ms;
-o-transition: filter 200ms;
transition: filter 200ms;
}
}
.hide-sensitive-btn {
position: absolute;
left: 6px;
top: 6px;
background-color: rgba(0,0,0,.6);
min-width: unset;
height: auto;
color: hsla(0,0%,100%,.7);
&:hover {
background-color: rgba(0,0,0,.9);
}
.mu-button-wrapper {
padding: 0;
}
}
.light-box {
.mu-dialog {
background-color: transparent;
max-width: unset;
.close-icon {
font-size: 46px;
position: fixed;
z-index: 1;
right: 20px;
top: 20px;
cursor: pointer;
color: #fff;
}
.mu-dialog-body {
padding: 0;
width: 100vw;
height: 100vh;
.mu-carousel {
height: 100%;
.mu-carousel-item {
display: flex;
align-items: center;
justify-content: center;
.light-box-item {
img {
max-width: 100vw;
max-height: 80vh;
width: auto;
height: auto;
}
}
}
}
}
}
}
</style>
================================================
FILE: src/components/StatusCard/PlaceHolderMediaItem.vue
================================================
<template>
<div class="placeholder-media-item-container" :style="placeHolderItemStyle">
<div class="placeholder-area" v-if="!showSensitiveCover && !isMediaLoaded">
<mu-icon class="status-icon" :value="placeHolderStatusIconValue"/>
</div>
<img v-show="isMediaLoaded" v-if="(mediaType === mediaTypes.IMAGE)"
:class="[showSensitiveCover && 'sensitive-hide']"
:src="url" @load="onMediaContentLoaded" @error="onMediaContentLoadFailed"/>
<div class="gifv-container" v-show="isMediaLoaded" v-if="mediaType === mediaTypes.GIFV || mediaType === mediaTypes.VIDEO">
<video width="100%" controls :loop="mediaType === mediaTypes.GIFV"
:class="[showSensitiveCover && 'sensitive-hide']"
:src="url" @loadstart="onMediaContentLoaded" @error="onMediaContentLoadFailed"/>
</div>
<mu-button class="hide-sensitive-btn" v-show="!showSensitiveCover && isMediaLoaded" @click.stop="onShowSensitiveCover">
<mu-icon value="visibility_off"/>
</mu-button>
</div>
</template>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator'
import { AttachmentTypes } from '@/constant'
@Component({})
class PlaceHolderMediaItem extends Vue {
@Prop() url: string
@Prop() mediaType: string
@Prop() shouldShowSensitiveCover: boolean
@Prop() holderStyle
get showSensitiveCover () {
return this.shouldShowSensitiveCover
}
get placeHolderItemStyle () {
return this.isMediaLoaded ? null : this.holderStyle
}
get placeHolderStatusIconValue () {
return this.isMediaLoadFailed ? 'broken_image' : this.mediaType === AttachmentTypes.IMAGE ? 'photo' : 'videocam'
}
get isVideo () {
return null
}
mediaTypes = AttachmentTypes
isMediaLoaded = false
isMediaLoadFailed = false
onShowSensitiveCover () {
this.$emit('update:shouldShowSensitiveCover', true)
}
onMediaContentLoaded () {
this.isMediaLoaded = true
}
onMediaContentLoadFailed () {
this.isMediaLoadFailed = true
}
}
export default PlaceHolderMediaItem
</script>
<style lang="less" scoped>
.placeholder-media-item-container {
width: auto;
max-width: 100%;
.placeholder-area {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #ccc;
color: #b0b0b0;
.status-icon {
font-size: 30px;
}
}
.gifv-container {
width: 100%;
}
.sensitive-hide {
filter: blur(20px);
}
}
</style>
================================================
FILE: src/components/StatusCard/SimpleActionBar.vue
================================================
<template>
<div class="simple-action-bar">
<div class="left-area">
<mu-avatar v-if="isOAuthUser" class="current-user-avatar" slot="avatar" size="24">
<img :src="currentUserAccount.avatar">
</mu-avatar>
<div v-if="isOAuthUser" :style="activeReplyEntryStyle"
class="active-reply-entry placeholder-read-text-color"
@click="onReplyToStatus">
{{$t($i18nTags.statusCard.reply_to_main_status)}}
</div>
</div>
<div class="right-area">
<div class="plus-one operate-btn-group">
<mu-button :disabled="!isOAuthUser" class="circle-btn" icon @click="onFavoriteButtonClick"
:class="{ 'primary-theme-bg-color': operateCheckTargetStatus.favourited }">
+1
</mu-button>
<span v-if="operateCheckTargetStatus.favourites_count > 0" class="count">{{operateCheckTargetStatus.favourites_count}}</span>
</div>
<div class="share operate-btn-group" v-if="shouldShowReblogButton">
<mu-button :disabled="!isOAuthUser" class="circle-btn unset-display" @click="onReBlogButtonClick"
:class="{ 'primary-theme-bg-color': operateCheckTargetStatus.reblogged }" icon>
<mu-icon class="share-icon" value="share" />
</mu-button>
<span v-if="operateCheckTargetStatus.reblogs_count > 0" class="count">{{operateCheckTargetStatus.reblogs_count}}</span>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator'
import { State, Getter, Action } from 'vuex-class'
import { I18nLocales, VisibilityTypes } from '@/constant'
import { mastodonentities, cuckoostore } from '@/interface'
@Component({})
class SimpleActionBar extends Vue {
@Prop() status: mastodonentities.Status
@State('currentUserAccount') currentUserAccount: mastodonentities.AuthenticatedAccount
@State('appStatus') appStatus
@Getter('isOAuthUser') isOAuthUser
@Action('updateFavouriteStatusById') updateFavouriteStatusById
@Action('updateReblogStatusById') updateReblogStatusById
get shouldShowReblogButton () {
return this.status.visibility !== VisibilityTypes.DIRECT
}
get operateCheckTargetStatus () {
return this.status.reblog || this.status
}
get activeReplyEntryStyle () {
if (this.appStatus.settings.locale === I18nLocales.JA) {
return {
fontSize: '12px'
}
}
}
onFavoriteButtonClick () {
const mainStatusId = this.status.id
const targetStatusId = this.operateCheckTargetStatus.id
this.updateFavouriteStatusById({
favourited: !this.operateCheckTargetStatus.favourited,
mainStatusId, targetStatusId
})
}
onReBlogButtonClick () {
const mainStatusId = this.status.id
const targetStatusId = this.operateCheckTargetStatus.id
this.updateReblogStatusById({
reblogged: !this.operateCheckTargetStatus.reblogged,
mainStatusId, targetStatusId
})
}
onReplyToStatus () {
this.$emit('reply', this.status)
}
get operateBtnStyle () {
if (!this.isOAuthUser) {
return {
cursor: ''
}
}
}
}
export default SimpleActionBar
</script>
<style lang="less" scoped>
.simple-action-bar {
min-height: 60px;
display: flex;
justify-content: space-between;
.left-area {
padding: 12px 16px;
display: flex;
align-items: center;
flex-grow: 1;
.active-reply-entry {
margin-left: 16px;
height: 36px;
font-size: 14px;
line-height: 36px;
font-weight: 300;
flex-grow: 1;
}
}
.right-area {
margin: 12px 8px;
display: flex;
align-items: center;
flex-shrink: 0;
.operate-btn-group {
display: flex;
&.plus-one {
font-size: 12px;
}
.share-icon {
font-size: 18px;
}
.count {
line-height: 36px;
font-size: 13px;
margin-right: 6px;
}
}
}
}
</style>
================================================
FILE: src/components/StatusCard/index.vue
================================================
<template>
<div class="status-card-container" @dragenter="onDragFileOver" ref="statusCardContainer">
<mu-card class="status-card status-card-bg-color" v-loading="isCardLoading"
v-drag-over="isFileDragOver"
@cuckooDragOver="onDragFileOver"
@cuckooDragleave="isFileDragOver = false"
@cuckooDrop="onDropFile">
<card-header :status="status" @deleteStatus="isCardLoading = true"
@muteStatus="onMuteStatus" @muteUser="onMuteUser"/>
<div class="spoiler-text-area primary-read-text-color" v-if="status.spoiler_text">
<span v-html="status.spoiler_text"/>
<mu-button flat small class="secondary-theme-text-color" :style="{ minWidth: 'unset' }"
@click="shouldShowContentWhileSpoilerExists = !shouldShowContentWhileSpoilerExists">
{{ $t(shouldShowContentWhileSpoilerExists ? $i18nTags.statusCard.hide_content : $i18nTags.statusCard.show_content) }}
</mu-button>
</div>
<mu-card-text v-if="!status.reblog && status.content" v-show="(status.spoiler_text ? shouldShowContentWhileSpoilerExists : true)"
class="status-content main-status-content"
v-html="status.content" :style="mainStatusContentStyle"/>
<div v-if="neteaseMusicLink" class="netease-music-panel">
<iframe class="netease-music-iframe" frameborder="no" border="0"
marginwidth="0" marginheight="0" height=86
:src="neteaseMusicLink"></iframe>
</div>
<div v-if="youtubeVideoLink" class="youtube-video-panel">
<iframe class="youtube-video-iframe"
:height="youtubeVideoIFrameHeight"
:src="youtubeVideoLink"
frameborder="0"
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen></iframe>
</div>
<div v-if="!status.reblog && hasLinkCardInfo" class="main-link-preview-area">
<mu-divider class="link-preview-divider"/>
<link-preview-panel :cardInfo="cardMap[status.id]"/>
</div>
<mu-divider v-if="!status.media_attachments.length && !(status.pixiv_cards || []).length"/>
<div v-if="!status.reblog" class="main-attachment-area">
<media-panel :mediaList="status.media_attachments"
:pixivCards="status.pixiv_cards"
:cardInfo="mainStatusCardInfo" :sensitive="status.sensitive"/>
</div>
<div v-if="status.reblog" class="reblog-area">
<div class="reblog-plain-info-area">
<a @click="onCheckSharedOriginalPost" class="reblog-source-link" v-html="$t($i18nTags.statusCard.originally_shared_by, {
displayName: status.reblog.account.display_name,
atName: getAccountAtName(status.reblog.account)
})">
</a>
<mu-card-text v-if="status.reblog.content" class="status-content reblog-status-content" v-html="status.reblog.content" />
</div>
<div v-if="reblogNeteaseMusicLink" class="netease-music-panel">
<iframe class="netease-music-iframe" frameborder="no" border="0"
marginwidth="0" marginheight="0" height=86
:src="reblogNeteaseMusicLink"></iframe>
</div>
<div v-if="reblogYoutubeVideoLink" class="youtube-video-panel">
<iframe class="youtube-video-iframe"
:height="youtubeVideoIFrameHeight"
:src="reblogYoutubeVideoLink"
frameborder="0"
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen></iframe>
</div>
<div v-if="reblogHasLinkCardInfo" class="main-link-preview-area">
<mu-divider class="link-preview-divider"/>
<link-preview-panel :cardInfo="cardMap[status.reblog.id]"/>
</div>
<div class="reblog-attachment-area">
<media-panel
:mediaList="status.reblog.media_attachments"
:pixivCards="status.reblog.pixiv_cards"
:cardInfo="cardMap[status.reblog.id]" :sensitive="status.reblog.sensitive"/>
</div>
</div>
<div class="reply-area-full">
<div class="full-reply-list" ref="replyListContainer">
<full-reply-list-item v-for="replierStatus in descendantStatusList" @muteStatus="onMuteStatus" @muteUser="onMuteUser"
:key="replierStatus.id" :status="replierStatus" @reply="onReplyToStatus(replierStatus)"/>
</div>
</div>
<div class="current-reply-to-info-area" v-if="currentReplyToStatus">
<mu-chip class="reply-to-account-info" color="primary" @delete="hideFullReplyActionArea" delete>
<mu-avatar :size="32">
<img :src="currentReplyToStatus.account.avatar">
</mu-avatar>
<span v-html="currentReplyToStatus.account.display_name"/>
<span> @{{currentReplyToStatus.account.username}}</span>
</mu-chip>
</div>
<mu-card-actions class="card-action-area">
<simple-action-bar v-show="!shouldShowFullReplyActionArea" :status="status"
@reply="onReplyToStatus(status)"/>
<full-action-bar v-if="isOAuthUser && shouldShowFullReplyActionArea"
:currentReplyToStatus="currentReplyToStatus"
:descendantStatusList="descendantStatusList"
:droppedFiles="droppedFiles" :replySpoilerText.sync="replySpoilerText"
:status="status" :value.sync="replyInputValue" @hide="hideFullReplyActionArea"
@loadingStart="isCardLoading = true" @loadingEnd="isCardLoading = false" @replySuccess="onReplySuccess"/>
</mu-card-actions>
</mu-card>
</div>
</template>
<script lang="ts">
import { Vue, Component, Prop, Watch } from 'vue-property-decorator'
import { State, Getter, Mutation } from 'vuex-class'
import { mastodonentities } from '@/interface'
import { StatusCardTypes } from '@/constant'
import * as $ from 'jquery'
import CardHeader from './CardHeader'
import MediaPanel from './MediaPanel'
import LinkPreviewPanel from './LinkPreviewPanel'
import FullReplyListItem from './FullReplyListItem'
import SimpleActionBar from './SimpleActionBar'
import FullActionBar from './FullActionBar'
import VisibilitySelectPopOver from '@/components/VisibilitySelectPopOver'
import { getNetEaseMusicFrameLinkFromContentLink, getYoutubeVideoFrameLinkFromContentLink } from '@/util'
@Component({
components: {
'card-header': CardHeader,
'media-panel': MediaPanel,
'link-preview-panel': LinkPreviewPanel,
'full-reply-list-item': FullReplyListItem,
'simple-action-bar': SimpleActionBar,
'full-action-bar': FullActionBar,
'visibility-select-pop-over': VisibilitySelectPopOver
}
})
class StatusCard extends Vue {
$router
$routersInfo
$refs: {
replyListContainer: HTMLDivElement
}
$confirm
$t
$i18nTags
@State('contextMap') contextMap
@State('statusMap') statusMap
@State('cardMap') cardMap
@State('currentUserAccount') currentUserAccount: mastodonentities.AuthenticatedAccount
@State('appStatus') appStatus
@Getter('getAccountAtName') getAccountAtName
@Getter('isOAuthUser') isOAuthUser
@Mutation('updateMuteStatusList') updateMuteStatusList
@Mutation('updateMuteUserList') updateMuteUserList
currentReplyToStatus: mastodonentities.Status = null
shouldShowContentWhileSpoilerExists_ = null
shouldShowFullReplyActionArea: boolean = false
replyInputValue: string = ''
replySpoilerText: string = ''
isCardLoading = false
isFileDragOver = false
youtubeVideoIFrameHeight = 0
droppedFiles: Array<File> = null
@Prop() status: mastodonentities.Status
@Prop() shouldCollapseContent: boolean
mounted () {
this.youtubeVideoIFrameHeight = this.$refs.replyListContainer.clientWidth * 315 / 560
}
get mainStatusCardInfo (): mastodonentities.Card {
return this.cardMap[this.status.id]
}
get contentLinkList () {
return [...$(this.status.content).find('a')].map(a => {
return a.getAttribute('href')
})
}
get neteaseMusicLink () {
return this.contentLinkList.map(link => {
return getNetEaseMusicFrameLinkFromContentLink(link)
}).filter(l => l)[0]
}
get youtubeVideoLink () {
return this.contentLinkList.map(link => {
return getYoutubeVideoFrameLinkFromContentLink(link)
}).filter(l => l)[0]
}
get reblogContentLinkList () {
return this.status.reblog ? [...$(this.status.reblog.content).find('a')].map(a => {
return a.getAttribute('href')
}) : []
}
get reblogNeteaseMusicLink () {
return this.reblogContentLinkList.map(link => {
return getNetEaseMusicFrameLinkFromContentLink(link)
}).filter(l => l)[0]
}
get reblogYoutubeVideoLink () {
return this.reblogContentLinkList.map(link => {
return getYoutubeVideoFrameLinkFromContentLink(link)
}).filter(l => l)[0]
}
get hasLinkCardInfo () {
return this.mainStatusCardInfo &&
(Object.keys(this.mainStatusCardInfo).length !== 0)
&& this.mainStatusCardInfo.type === StatusCardTypes.LINK
}
get reblogHasLinkCardInfo () {
return this.status.reblog &&
this.cardMap[this.status.reblog.id] &&
(Object.keys(this.cardMap[this.status.reblog.id]).length !== 0) &&
this.cardMap[this.status.reblog.id].type === StatusCardTypes.LINK
}
get shouldShowContentWhileSpoilerExists () {
if (typeof this.shouldShowContentWhileSpoilerExists_ === 'boolean') {
return this.shouldShowContentWhileSpoilerExists_
}
return this.appStatus.settings.autoExpandSpoilerTextMode
}
set shouldShowContentWhileSpoilerExists (val) {
this.shouldShowContentWhileSpoilerExists_ = val
}
get descendantStatusList (): Array<mastodonentities.Status> {
if (!this.contextMap[this.status.id] || !this.contextMap[this.status.id].descendants) return []
return this.contextMap[this.status.id].descendants.map(descendantStatusId => {
return this.statusMap[descendantStatusId]
}).filter(s => s).sort((a, b) => {
return new Date(a.created_at) >= new Date(b.created_at) ? 1 : -1
}).filter(status => {
const muteByStatus = this.appStatus.settings.muteMap.statusList.indexOf(status.id) !== -1
const muteByUser = this.appStatus.settings.muteMap.userList.indexOf(status.account.id) !== -1
return !muteByStatus && !muteByUser
})
}
get mainStatusContentStyle () {
return this.shouldCollapseContent ? {
'max-height': '500px',
'overflow': 'auto'
} : null
}
@Watch('shouldShowFullReplyActionArea')
onFullReplyActionAreaDisplayToggled (val) {
if (val) this.$emit('statusCardFocus')
}
hideFullReplyActionArea () {
this.shouldShowFullReplyActionArea = false
this.currentReplyToStatus = null
this.replyInputValue = ''
this.replySpoilerText = ''
this.droppedFiles = []
}
onCheckSharedOriginalPost () {
this.$router.push({
name: this.$routersInfo.statuses.name,
params: {
statusId: this.status.reblog.id
}
})
}
onReplyToStatus (status: mastodonentities.Status) {
this.currentReplyToStatus = status
let preSetMentions
if (this.appStatus.settings.onlyMentionTargetUserMode) {
preSetMentions = [{ acct: status.account.acct }]
} else {
preSetMentions = status.mentions.filter(mention => {
return (mention.id !== this.currentUserAccount.id) && (mention.id !== status.account.id)
})
if (status.account.id !== this.currentUserAccount.id || preSetMentions.length === 0) {
preSetMentions.unshift({
acct: status.account.acct,
id: status.account.id
} as mastodonentities.Mention)
}
}
this.replyInputValue = preSetMentions.reduce((pre, cur) => pre + `@${cur.acct} `, '')
this.shouldShowFullReplyActionArea = true
}
onReplySuccess () {
this.$nextTick(() => {
this.$refs.replyListContainer.scrollTo({ top: this.$refs.replyListContainer.scrollHeight, left: 0, behavior: 'smooth' })
})
}
onDragFileOver (e: DragEvent) {
e.preventDefault()
this.isFileDragOver = true
}
onDropFile (e: DragEvent) {
e.preventDefault()
this.isFileDragOver = false
if (!this.shouldShowFullReplyActionArea) {
this.onReplyToStatus(this.status)
}
this.droppedFiles = Array.from(e.dataTransfer.files)
}
async onMuteStatus (statusId: string) {
const doMuteStatus = (await this.$confirm(this.$t(this.$i18nTags.statusCard.mute_status_confirm), {
okLabel: this.$t(this.$i18nTags.statusCard.do_mute_status_btn),
cancelLabel: this.$t(this.$i18nTags.statusCard.cancel_mute_status_btn),
})).result
if (doMuteStatus) {
this.updateMuteStatusList(statusId)
}
}
async onMuteUser (userId: string) {
const doMuteUser = (await this.$confirm(this.$t(this.$i18nTags.statusCard.mute_user_confirm), {
okLabel: this.$t(this.$i18nTags.statusCard.do_mute_user_btn),
cancelLabel: this.$t(this.$i18nTags.statusCard.cancel_mute_user_btn),
})).result
if (doMuteUser) {
this.updateMuteUserList(userId)
}
}
}
export default StatusCard
</script>
<style lang="less" scoped>
.status-card-container {
width: 100%;
.status-card {
height: 100%;
display: flex;
flex-direction: column;
transition: box-shadow 0.3s ease-in-out;
}
}
.at-name {
font-weight: 400;
font-size: 13px;
}
.spoiler-text-area {
padding: 0 16px 16px;
}
.main-status-content {
padding: 0 16px 16px;
}
.main-link-preview-area {
padding: 0 16px 16px 16px;
.link-preview-divider {
margin-bottom: 16px;
}
}
.main-attachment-area {
.attachment-list {
> img {
width: 100%;
height: auto;
}
}
}
.reblog-area {
.reblog-plain-info-area {
margin: 16px;
.reblog-source-link {
cursor: pointer;
font-weight: 500;
.at-name {
color: unset;
}
}
.reblog-status-content {
padding: 0;
margin-top: 8px;
}
}
}
.reply-area-full {
.full-reply-list {
max-height: 400px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.full-reply-status-content {
padding: 0;
}
}
.current-reply-to-info-area {
height: 44px;
line-height: 44px;
padding-left: 16px;
.reply-to-account-info {
margin-top: 6px;
}
}
.card-action-area {
padding: 0;
}
</style>
<style lang="less">
.status-content {
// https://stackoverflow.com/questions/5241369/word-wrap-a-link-so-it-doesnt-overflow-its-parent-div-width
word-wrap: break-word;
white-space: pre-wrap;
> p {
margin: 0 0 10px 0;
padding: 0;
}
> P:last-child {
margin-bottom: 0;
}
}
.simple-reply-status-content {
> p { display: inline }
}
.reply-text-input {
.el-textarea__inner {
width: 100%;
outline: none;
border: none;
padding: 0;
resize: none;
}
}
.no-limit-reply-area-height.status-card-container {
.full-reply-list {
max-height: unset;
}
}
</style>
================================================
FILE: src/components/ThemeEditPanel.vue
================================================
<template>
<div class="theme-edit-panel-container">
<mu-dialog :open.sync="isDialogOpening" overlay-color="rgba(0,0,0,0.12)"
dialog-class="theme-edit-dialog default-theme-bg-color" :width="dialogWidth"
:overlay-close="false"
:overlay-opacity="1" :transition="transition" :fullscreen="shouldDialogFullScreen">
<mu-appbar color="secondary">
<mu-button slot="right" icon @click="onMinimizePanel">
<mu-icon value="exit_to_app" />
</mu-button>
</mu-appbar>
<div class="color-select-area">
<mu-card class="color-select-card" v-for="(colorInfo, index) in colorPickPanelOrder" :key="index">
<div class="color-info">
<span class="color-name primary-read-text-color">{{colorInfo.label}}</span>
<span class="color-value secondary-read-text-color">{{colorInfo.value}}</span>
</div>
<div class="color-cake" ref="colorCakes" :style="{ backgroundColor: colorInfo.value }"
@click="onColorCakeClick(colorInfo, index)">
<mu-ripple />
</div>
</mu-card>
</div>
<mu-button slot="actions" flat color="secondary" @click="onTryToCancelEdit">Cancel</mu-button>
<mu-button slot="actions" flat class="secondary-theme-text-color" @click="onTryToSaveTheme" :disabled="!hasColorChanged">Save</mu-button>
</mu-dialog>
<mu-button fab class="minimize-theme-edit-button" color="secondary" v-show="!isDialogOpening"
@click="onMaximisePanel">
<mu-icon value="color_lens" />
</mu-button>
<mu-popover cover :trigger="triggerPopOverElem"
:open.sync="shouldOpenColorPickerPopOver">
<mu-tabs :value.sync="activeTabIndex">
<mu-tab>Simple</mu-tab>
<mu-tab>Advanced</mu-tab>
</mu-tabs>
<div class="color-pickers-container">
<swatches-picker v-show="activeTabIndex === 0" :value="currentEditColorInfo.value" @input="onColorPickerInput"/>
<chrome-picker v-show="activeTabIndex === 1" :value="currentEditColorInfo.value" @input="onColorPickerInput"/>
</div>
</mu-popover>
</div>
</template>
<script lang="ts">
import { Vue, Component, Watch } from 'vue-property-decorator'
import { State, Getter, Mutation } from 'vuex-class'
import { UiWidthCheckConstants } from '@/constant'
import ThemeManager from '@/themes'
import { Chrome, Compact, Swatches } from 'vue-color'
import * as _ from 'underscore'
const themeColorNameToDataNameMap = {
primaryColor: 'primaryColor',
secondaryColor: 'secondaryColor',
trackColor: 'placeholderTextColor',
textColor: 'primaryTextColor',
secondaryTextColor: 'secondaryTextColor',
disabledColor: 'disabledColor',
backgroundColor: 'primaryBGColor',
dialogBackgroundColor: 'secondaryBGColor'
}
const colorPickPanelNameOrder = [
'primaryColor', 'secondaryColor',
'primaryTextColor', 'secondaryTextColor',
'placeholderTextColor', 'disabledColor',
'primaryBGColor', 'secondaryBGColor'
]
@Component({
components: {
'swatches-picker': Swatches,
'chrome-picker': Chrome
}
})
class ThemeEditPanel extends Vue {
$refs: {
colorCakes: any
}
$confirm
$prompt
@State('appStatus') appStatus
@Getter('shouldDialogFullScreen') shouldDialogFullScreen
@Mutation('updateIsEditingThemeMode') updateIsEditingThemeMode
@Mutation('updateShouldShowThemeEditPanel') updateShouldShowThemeEditPanel
@Mutation('updateTheme') updateTheme
currentEditColorInfo = {
label: '',
value: ''
}
activeTabIndex: number = 0
shouldOpenColorPickerPopOver: boolean = false
primaryColor: string = ''
secondaryColor: string = ''
primaryTextColor: string = ''
secondaryTextColor: string = ''
placeholderTextColor: string = ''
disabledColor: string = ''
primaryBGColor: string = ''
secondaryBGColor: string = ''
triggerPopOverElem: any = null
onSomeColorChangedListener = () => {}
hasColorChanged: boolean = false
mounted () {
const currentThemeInfo = ThemeManager.getThemeInfoByThemeName(this.appStatus.settings.theme)
this.initColorList(currentThemeInfo.theme.colorSet)
this.onSomeColorChangedListener = _.throttle(() => {
const currentColorSet = this.getCurrentColorSet()
ThemeManager.setTempThemeByColorSet(currentColorSet)
}, 20)
}
get colorPickPanelOrder () {
return colorPickPanelNameOrder.map(colorName => {
return { label: colorName, value: this[colorName] }
})
}
get isDialogOpening () {
return this.appStatus.shouldShowThemeEditPanel
}
set isDialogOpening (show) {
this.updateShouldShowThemeEditPanel(show)
}
get transition () {
return this.shouldDialogFullScreen ? 'slide-bottom' : 'slide-top'
}
get dialogWidth () {
return this.shouldDialogFullScreen ? null : UiWidthCheckConstants.POST_STATUS_DIALOG_TOGGLE_WIDTH
}
@Watch('colorPickPanelOrder')
onSomeColorChanged () {
this.onSomeColorChangedListener()
}
getCurrentColorSet () {
const colorSet = {}
Object.keys(themeColorNameToDataNameMap).forEach(colorName => {
colorSet[`@${colorName}`] = this[themeColorNameToDataNameMap[colorName]]
})
return colorSet
}
initColorList (colorSet) {
Object.keys(themeColorNameToDataNameMap).forEach(colorName => {
this[themeColorNameToDataNameMap[colorName]] = colorSet[`@${colorName}`]
})
}
async onTryToCancelEdit () {
if (this.hasColorChanged) {
const doClosePanel = (await this.$confirm('Exit without save your editing?', {
okLabel: 'Yes',
cancelLabel: 'No',
})).result
if (doClosePanel) {
ThemeManager.setTheme(this.appStatus.settings.theme)
this.updateIsEditingThemeMode(false)
}
} else {
this.updateIsEditingThemeMode(false)
}
}
async onTryToSaveTheme () {
this.$prompt('Please naming your new theme', 'Theme Name', {
validator (value) {
return {
valid: !ThemeManager.themeInfo[value],
message: 'theme name conflict'
}
}
}).then(({ result, value }) => {
if (result) {
ThemeManager.importTheme(this.getCurrentColorSet(), value)
this.updateTheme(value)
ThemeManager.setTheme(value)
this.updateIsEditingThemeMode(false)
}
})
}
onMinimizePanel () {
this.isDialogOpening = false
}
onMaximisePanel () {
this.isDialogOpening = true
}
onColorCakeClick (colorInfo, index) {
this.triggerPopOverElem = this.$refs.colorCakes[index]
this.currentEditColorInfo = colorInfo
this.shouldOpenColorPickerPopOver = true
}
onColorPickerInput (val) {
this.hasColorChanged = true
const newColorRGBA = `rgba(${val.rgba.r}, ${val.rgba.g}, ${val.rgba.b}, ${val.rgba.a})`
this.currentEditColorInfo.value = newColorRGBA
this[this.currentEditColorInfo.label] = newColorRGBA
}
}
export default ThemeEditPanel
</script>
<style lang="less" scoped>
.theme-edit-panel-container {
.minimize-theme-edit-button {
position: fixed;
right: 16px;
bottom: 16px;
@media (min-width: 768px) {
right: 32px;
bottom: 32px;
}
}
}
.theme-edit-dialog {
.color-select-area {
padding: 16px;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
.color-select-card {
user-select: none;
min-width: 240px;
display: flex;
height: 60px;
padding: 14px;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
.color-info {
height: 36px;
display: flex;
flex-direction: column;
justify-content: center;
.color-name {
font-size: 16px;
height: 16px;
line-height: 16px;
}
.color-value {
font-size: 10px;
height: 20px;
line-height: 14px;
padding-top: 6px;
text-transform: uppercase;
letter-spacing: 1.5px;
}
}
.color-cake {
height: 36px;
width: 36px;
box-sizing: border-box;
border-radius: 100%;
cursor: pointer;
border: 1px solid #dadada;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.14);
position: relative;
}
}
}
}
.color-pickers-container {
display: flex;
justify-content: center;
}
</style>
<style lang="less">
.theme-edit-dialog {
border-radius: 4px;
.mu-dialog-body {
padding: 0;
height: auto !important;
}
}
</style>
================================================
FILE: src/components/VisibilitySelectPopOver.vue
================================================
<template>
<mu-popover cover :open.sync="shouldOpen"
:trigger="trigger">
<mu-list textline="two-line">
<mu-list-item button v-for="(visibilityType, index) in VisibilityTypeList"
:class="{ 'selected-item': visibilityType === visibility }"
:key="index" @click.stop="onChangeVisibility(visibilityType)">
<mu-list-item-action>
<mu-icon :value="getVisibilityDescInfo(visibilityType).icon"></mu-icon>
</mu-list-item-action>
<mu-list-item-content>
<mu-list-item-title class="primary-read-text-color">{{$t(visibilityType)}}</mu-list-item-title>
<mu-list-item-sub-title class="secondary-read-text-color">{{$t(getVisibilityDescInfo(visibilityType).descTag)}}</mu-list-item-sub-title>
</mu-list-item-content>
</mu-list-item>
</mu-list>
</mu-popover>
</template>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator'
import { VisibilityTypes } from '@/constant'
import { getVisibilityDescInfo } from '@/util'
@Component({})
class VisibilitySelectPopOver extends Vue {
@Prop() visibility: string
@Prop() open: boolean
@Prop() trigger: HTMLElement
getVisibilityDescInfo = getVisibilityDescInfo
VisibilityTypeList = [
VisibilityTypes.PUBLIC, VisibilityTypes.PRIVATE,
VisibilityTypes.UNLISTED, VisibilityTypes.DIRECT
]
get shouldOpen () {
return this.open
}
set shouldOpen (open) {
this.$emit('update:open', open)
}
onChangeVisibility (newVisibility: string) {
this.shouldOpen = false
this.$emit('update:visibility', newVisibility)
}
}
export default VisibilitySelectPopOver
</script>
<style lang="less" scoped>
</style>
================================================
FILE: src/constant/i18n.ts
================================================
export const I18nLocales = {
EN: 'en',
JA: 'ja',
DE: 'de',
ZH_CN: 'zh-cn',
ZH_HK: 'zh-hk',
ZH_TW: 'zh-tw'
}
export const I18nTags = {
common: {
status_visibility_public: 'public',
status_visibility_private: 'private',
status_visibility_unlisted: 'unlisted',
status_visibility_direct: 'direct',
status_visibility_public_desc: 'public_desc',
status_visibility_private_desc: 'private_desc',
status_visibility_unlisted_desc: 'unlisted_desc',
status_visibility_direct_desc: 'direct_desc',
drag_and_drop_to_upload: 'drag_and_drop_to_upload',
write_your_warning_here: 'write_your_warning_here'
},
statusCard: {
post_new_status_placeholder: 'post_new_status_placeholder',
reply_to_replier: 'status_card_reply_to_replier',
reply_to_main_status: 'status_card_reply_to_main_status',
cancel_post: 'status_card_cancel_post',
submit_post: 'status_card_submit_post',
show_content: 'status_card_show_content',
hide_content: 'status_card_hide_content',
mute_status: 'status_card_mute_status',
mute_user: 'status_card_mute_user',
delete_status: 'status_card_delete_status',
delete_status_confirm: 'status_card_delete_status_confirm',
do_delete_status_btn: 'status_card_do_delete_status_btn',
cancel_delete_status_btn: 'status_card_cancel_delete_status_btn',
originally_shared_by: 'status_card_originally_shared_by',
sensitive_media_alert: 'status_card_sensitive_media_alert',
change_visibility: 'status_card_change_visibility',
add_photos: 'status_card_add_photos',
mute_status_confirm: 'status_card_mute_status_confirm',
do_mute_status_btn: 'status_card_do_mute_status_btn',
cancel_mute_status_btn: 'status_card_cancel_mute_status_btn',
mute_user_confirm: 'status_card_mute_user_confirm',
do_mute_user_btn: 'status_card_do_mute_user_btn',
cancel_mute_user_btn: 'status_card_cancel_mute_user_btn',
},
timeLines: {
no_load_more_status_notice: 'timeLines_no_load_more_status_notice',
new_message_notice: 'timeLines_new_message_notice',
whats_new_with_you: 'timeLines_whats_new_with_you'
},
drawer: {
home: 'drawer_home',
public: 'drawer_public',
tag: 'drawer_tag',
local: 'drawer_local',
profile: 'drawer_profile',
settings: 'drawer_settings',
logout: 'drawer_logout',
do_logout_message_confirm: 'drawer_do_logout_message_confirm',
do_logout_message_yes: 'drawer_do_logout_message_yes',
do_logout_message_no: 'drawer_do_logout_message_no',
toHostInstance: 'drawer_to_host_instance',
search_input_placeholder: 'drawer_search_input_placeholder',
search_result_people_label: 'drawer_search_result_people_label',
search_result_hashtag_label: 'drawer_search_result_hashtag_label'
},
settings: {
general_label: 'settings_general_label',
choose_theme: 'settings_choose_theme',
export_theme_color_set: 'settings_export_theme_color_set',
import_theme_color_set: 'settings_import_theme_color_set',
edit_theme_color_set: 'settings_edit_theme_color_set',
delete_theme_color_set: 'settings_delete_theme_color_set',
choose_language: 'settings_choose_language',
use_multi_line_mode: 'settings_use_multi_line_mode',
maximum_number_of_columns_in_multi_line_mode: 'settings_maximum_number_of_columns_in_multi_line_mode',
show_sensitive_media_files: 'settings_show_sensitive_media_files',
auto_expand_spoiler_text: 'settings_auto_expand_spoiler_text',
auto_load_new_status: 'settings_auto_load_new_status',
post_privacy: 'settings_post_privacy',
post_media_as_sensitive: 'settings_post_media_as_sensitive',
only_mention_target_user: 'settings_only_mention_target_user',
stream_label: 'settings_stream_label',
media_label: 'settings_media_label',
publishing_label: 'settings_publishing_label',
personality_label: 'settings_personality_label',
web_label: 'settings_web_label',
changes_successfully_saved: 'settings_changes_successfully_saved'
},
header: {
},
home: {
},
oauth: {
form_brand: 'oauth_form_brand',
login_hint: 'oauth_login_hint',
server_input_label: 'oauth_server_input_label',
please_input_server_url: 'oauth_please_input_server_url',
please_input_correct_server_url: 'oauth_please_input_correct_server_url',
register_app_error_message: 'oauth_register_app_error_message',
confirm_input: 'oauth_confirm_input'
},
postStatusDialog: {
do_discard_message_confirm: 'post_status_dialog_do_discard_message_confirm',
do_keep_message: 'post_status_dialog_do_keep_message',
do_discard_message: 'post_status_dialog_do_discard_message',
text_character_limit_exceed: 'post_status_dialog_text_character_limit_exceed'
},
notifications: {
someone_followed_you: 'notifications_someone_followed_you',
mentioned_you: 'notifications_mentioned_you',
boosted_your_status: 'notifications_boosted_your_status',
favourited_your_status: 'notifications_favourited_your_status'
}
}
================================================
FILE: src/constant/index.ts
================================================
export { RoutersInfo } from './routers'
export { I18nTags, I18nLocales } from './i18n'
const AttachmentTypes = {
IMAGE: 'image',
VIDEO: 'video',
GIFV: 'gifv',
UNKNOWN: 'unknown'
}
const TimeLineTypes = {
PUBLIC: 'public',
HOME: 'home',
DIRECT: 'direct',
TAG: 'tag',
LIST: 'list',
LOCAL: 'local'
}
const VisibilityTypes = {
DIRECT: 'direct',
PRIVATE: 'private',
UNLISTED: 'unlisted',
PUBLIC: 'public'
}
const UiWidthCheckConstants = {
DRAWER_DOCKING_BOUNDARY: 960,
POST_STATUS_DIALOG_TOGGLE_WIDTH: 530,
NOTIFICATION_DIALOG_TOGGLE_WIDTH: 530,
DRAWER_DESKTOP_WIDTH: 290,
DRAWER_MOBILE_WIDTH: 300,
STATUS_CARD_MAX_WIDTH: 530,
STATUS_CARD_MIN_WIDTH: 356,
TIMELINE_WATER_FALL_GUTTER: 24
}
const ThemeNames = {
GOOGLE_PLUS: 'Google Plus',
DARK: 'Dark',
GREEN_LIGHT: 'Green Light',
CUCKOO_HUB: 'Cuckoo Hub'
}
const NotificationTypes = {
MENTION: 'mention',
REBLOG: 'reblog',
FAVOURITE: 'favourite',
FOLLOW: 'follow'
}
const StreamingEventTypes = {
UPDATE: 'update',
NOTIFICATION: 'notification',
DELETE: 'delete',
FILTERS_CHANGED: 'filters_changed'
}
const TITLE = 'Cuckoo+'
const StatusCardTypes = {
LINK: 'link',
PHOTO: 'photo'
}
export {
AttachmentTypes,
TimeLineTypes,
VisibilityTypes,
UiWidthCheckConstants,
ThemeNames,
NotificationTypes,
StreamingEventTypes,
TITLE,
StatusCardTypes
}
================================================
FILE: src/constant/routers.ts
================================================
export const RoutersInfo = {
empty: {
path: '/',
name: 'empty'
},
timelines: {
path: '/timelines',
name: 'timelines'
},
defaulttimelines: {
path: ':timeLineType',
name: 'defaulttimelines'
},
tagtimelines: {
path: 'tag/:tagName',
name: 'tagtimelines'
},
listtimelines: {
path: 'list/:listName',
name: 'listtimelines'
},
statuses: {
path: '/statuses/:statusId',
name: 'statuses'
},
home: {
path: '/home',
name: 'home'
},
oauth: {
path: '/oauth',
name: 'oauth'
},
settings: {
path: '/settings',
name: 'settings'
},
accounts: {
path: '/accounts/:accountId',
name: 'accounts'
}
};
================================================
FILE: src/directives.ts
================================================
import Vue from 'vue'
import * as Masonry from 'masonry-layout'
import ResizeObserver from 'resize-observer-polyfill'
import * as _ from 'underscore'
import { UiWidthCheckConstants } from '@/constant'
{
const createDragOverLayer = (vNode) => {
const layer = document.createElement('div')
layer.className = 'mu-loading-wrap drag-over-layer'
const component = vNode.componentInstance
layer.innerText = component.$t(component.$i18nTags.common.drag_and_drop_to_upload)
layer.addEventListener('dragover', e => e.preventDefault())
layer.addEventListener('dragleave', e => {
e.preventDefault()
component.$emit('cuckooDragleave', e)
})
layer.addEventListener('drop', e => {
e.preventDefault()
component.$emit('cuckooDrop', e)
})
return layer
}
Vue.directive('drag-over', {
update (el: HTMLDivElement, binding, vNode) {
if (binding.value === binding.oldValue) return
if (binding.value === false) {
return el.removeChild(el.querySelector('.drag-over-layer'))
}
// todo use singleton, but there is some little bug
el.appendChild(createDragOverLayer(vNode))
}
} as any)
}
{
interface MasonryItem {
element: HTMLDivElement
position: { x: number, y: number }
}
interface MasonryContainer extends HTMLDivElement {
$masonryEl: {
size: { width: number, height: number }
layout()
addItems(el)
reloadItems()
layoutItems(masonryItemList: Array<MasonryItem>)
items: Array<MasonryItem>
}
}
const reLayoutMasonry = _.throttle(($masonryEl) => {
$masonryEl.layout()
$masonryEl.items.forEach((item: MasonryItem) => {
item.element.style.animation = 'fadein 1s';
item.element.style.opacity = '1'
})}, 200)
Vue.directive('masonry-container', {
inserted (el: MasonryContainer) {
el.$masonryEl = new Masonry(el, {
itemSelector: '.status-card-container',
transitionDuration: 0,
gutter: UiWidthCheckConstants.TIMELINE_WATER_FALL_GUTTER,
initLayout: false
})
},
update: (el: MasonryContainer) => {
if (!el.parentElement || (el.parentElement.style.display === 'none')) return
const $masonryEl = el.$masonryEl
setTimeout(() => {
const oldItemLength = $masonryEl.items.length
$masonryEl.reloadItems()
const hasItemsLengthChanged = oldItemLength !== $masonryEl.items.length
const IsSomeItemHided = $masonryEl.items.some(item => item.element.style.opacity === '0')
if (!hasItemsLengthChanged && !IsSomeItemHided) return
reLayoutMasonry($masonryEl)
})
}
} as any)
const onMasonryItemSizeChanged = _.throttle(($masonryEl) => {
$masonryEl.layout()
}, 200)
const ro = new ResizeObserver((entries) => {
const targetMasonryContainer = entries[0].target.parentNode as MasonryContainer
if (!targetMasonryContainer || !targetMasonryContainer.$masonryEl) return
// todo optimize
return onMasonryItemSizeChanged(targetMasonryContainer.$masonryEl)
})
Vue.directive('masonry-item', {
inserted (el: HTMLDivElement) {
el.style.opacity = '0'
ro.observe(el)
}
} as any)
}
================================================
FILE: src/formatter.spec.ts
================================================
import 'mocha'
import { expect } from 'chai'
import Formatter from './formatter'
const formatter = new Formatter()
describe('formatter.format', () => {
it('ignores strings with no dashes', () => {
expect(formatter.format("aaaaaa")).to.equal("aaaaaa")
})
it('ignores strings with a single dash', () => {
expect(formatter.format("-aaaaaa")).to.equal("-aaaaaa")
expect(formatter.format("aaaaaa-")).to.equal("aaaaaa-")
expect(formatter.format("aaa-aaa")).to.equal("aaa-aaa")
expect(formatter.format("aaa -aaa")).to.equal("aaa -aaa")
expect(formatter.format("aaa- aaa")).to.equal("aaa- aaa")
expect(formatter.format("aaa - aaa")).to.equal("aaa - aaa")
})
it('format strings with a pair of correct dashes', () => {
expect(formatter.format("-aaaaaa-")).to.equal("<del>aaaaaa</del>")
expect(formatter.format("-aaa- aaa")).to.equal("<del>aaa</del> aaa")
expect(formatter.format("aaa -aaa-")).to.equal("aaa <del>aaa</del>")
expect(formatter.format("aa -aa- aa")).to.equal("aa <del>aa</del> aa")
})
it('ignore strings with a pair of wrong dashes', () => {
expect(formatter.format("-aaa-aaa")).to.equal("-aaa-aaa")
expect(formatter.format("aaa-aaa-")).to.equal("aaa-aaa-")
expect(formatter.format("aa-aa-aa")).to.equal("aa-aa-aa")
})
it('format string with a pair of correct dashes and a wrong dash', () => {
expect(formatter.format("-aaa-aaa-")).to.equal("<del>aaa-aaa</del>")
expect(formatter.format("-a-aa- aaa")).to.equal("<del>a-aa</del> aaa")
expect(formatter.format("-aaa- a-aa")).to.equal("<del>aaa</del> a-aa")
expect(formatter.format("aaa -a-aa-")).to.equal("aaa <del>a-aa</del>")
expect(formatter.format("a-aa -aaa-")).to.equal("a-aa <del>aaa</del>")
expect(formatter.format("aa -a-a- aa")).to.equal("aa <del>a-a</del> aa")
expect(formatter.format("a-a -aa- aa")).to.equal("a-a <del>aa</del> aa")
})
it('format string with a pair of correct dashes and a correct dash', () => {
expect(formatter.format("-a -aa- aaa")).to.equal("<del>a -aa</del> aaa")
expect(formatter.format("-aaa- a -aa")).to.equal("<del>aaa</del> a -aa")
expect(formatter.format("aaa -a -aa-")).to.equal("aaa <del>a -aa</del>")
expect(formatter.format("a- aa -aaa-")).to.equal("a- aa <del>aaa</del>")
expect(formatter.format("aa -a- a- aa")).to.equal("aa <del>a- a</del> aa")
expect(formatter.format("aa -a -a- aa")).to.equal("aa <del>a -a</del> aa")
expect(formatter.format("a- a -aa- aa")).to.equal("a- a <del>aa</del> aa")
})
// todo fix this
it('format string with two pairs of correct dashes', () => {
// expect(formatter.format("a -a- aa -a- a")).to.equal("a <del>a</del> aa <del>a</del> a")
// expect(formatter.format("-aa- aa -a- a")).to.equal("<del>aa</del> aa <del>a</del> a")
// expect(formatter.format("a -a- aa -aa-")).to.equal("a <del>a</del> aa <del>aa</del>")
// expect(formatter.format("-aa- aa -aa-")).to.equal("<del>aa</del> aa <del>aa</del>")
})
it('format string with two enclosing pairs of correct dashes', () => {
expect(formatter.format("a -a -aa- a- a")).to.equal("a <del>a -aa- a</del> a")
})
})
================================================
FILE: src/formatter.ts
================================================
import { mastodonentities } from "@/interface"
class Formatter {
private customEmojiRegex = /:\w+:/g
// todo fix test
private delRegex = /(^|\s)-.*-($|\s|\.|,|\?|!|~)/g
private boldRegex = /(^|\s)\*.*\*($|\s|\.|,|\?|!|~)/g
private italicRegex = /(^|\s)_.*_($|\s|\.|,|\?|!|~)/g
private customEmojiMap: {
[index: string]: mastodonentities.Emoji
} = {}
constructor (customEmojis: Array<mastodonentities.Emoji> = []) {
customEmojis.forEach(emoji => {
this.customEmojiMap[emoji.shortcode] = emoji
})
}
private insertSomething (regex: RegExp, fragment: string, tag: string) {
return (text: string) => {
return text.replace(regex, (matchString: string, p1, p2, index) => {
const trimString = matchString.trim()
const isFinalCharacterDel = trimString[trimString.length - 1] === `${fragment}`
const isMatchStringFinalPixel = (index + matchString.length) === text.length && isFinalCharacterDel
const centralSubString = trimString.substring(1, trimString.length - ( isFinalCharacterDel ? 1 : 2 ))
return `${matchString[0] === ' ' ? ' ' : ''}<${tag}>${centralSubString}</${tag}>${isMatchStringFinalPixel ? '' : matchString[matchString.length - 1]}`
})
}
}
public insertDels (text: string): string {
return this.insertSomething(this.delRegex, '-', 'del')(text)
}
public insertBolds (text: string): string {
return this.insertSomething(this.boldRegex, '*', 'strong')(text)
}
public insetItalic (text) {
return this.insertSomething(this.italicRegex, '_', 'i')(text)
}
private insertCustomEmojis (text: string): string {
return text.replace(this.customEmojiRegex, (matchString: string) => {
const emojiShortCode = matchString.trim().slice(1, -1)
const targetEmoji = this.customEmojiMap[emojiShortCode]
if (!targetEmoji) return matchString
return `<img class="custom-emoji" src="${targetEmoji.static_url}"/>`
})
}
public updateCustomEmojiMap (customEmojis: Array<mastodonentities.Emoji> = []) {
customEmojis.forEach(emoji => {
this.customEmojiMap[emoji.shortcode] = emoji
})
}
public format (text: string, customEmojis: Array<mastodonentities.Emoji> = []): string {
return [this.insertDels, this.insertBolds, this.insetItalic, this.insertCustomEmojis].reduce((preValue, process) => {
return process.bind(this)(preValue)
}, text)
}
}
export default Formatter
================================================
FILE: src/i18n/compare
================================================
#!/bin/bash
# Usage: ./compare en.ts ja.ts
diff <(cut -f 1 -d: "$1") <(cut -f 1 -d: "$2")
================================================
FILE: src/i18n/de.ts
================================================
import { I18nTags } from '@/constant'
const oauth = {
[I18nTags.oauth.form_brand]: 'Cuckoo Plus',
[I18nTags.oauth.login_hint]: 'Anmelden',
[I18nTags.oauth.server_input_label]: 'Mastodon URL',
[I18nTags.oauth.please_input_server_url]: 'Bitte Mastodon URL eingeben',
[I18nTags.oauth.please_input_correct_server_url]: 'Bitte Mastodon URL überprüfen',
[I18nTags.oauth.register_app_error_message]: 'Oops! Bitte Mastodon URL nochmals überprüfen',
[I18nTags.oauth.confirm_input]: 'Bestätigen'
}
const common = {
[I18nTags.common.status_visibility_public]: 'Öffentlich',
[I18nTags.common.status_visibility_unlisted]: 'Privat',
[I18nTags.common.status_visibility_private]: 'Nur Folgende',
[I18nTags.common.status_visibility_direct]: 'Direktnachrichten',
[I18nTags.common.status_visibility_public_desc]: 'In öffentlicher Zeitleiste teilen',
[I18nTags.common.status_visibility_unlisted_desc]: 'Nicht in öffentlicher Zeitleiste teilen',
[I18nTags.common.status_visibility_private_desc]: 'Nur mit den Folgers teilen',
[I18nTags.common.status_visibility_direct_desc]: 'Nur mit erwähnten Freunden teilen',
[I18nTags.common.drag_and_drop_to_upload]: 'Drag & Drop zum Hochladen',
[I18nTags.common.write_your_warning_here]: 'Bitte hier Warnung schreiben'
}
const statusCard = {
[I18nTags.statusCard.post_new_status_placeholder]: 'Gibt\'s etwas Neues?',
[I18nTags.statusCard.reply_to_main_status]: 'Kommentieren...',
[I18nTags.statusCard.reply_to_replier]: 'Antworten',
[I18nTags.statusCard.cancel_post]: 'Abbrechen',
[I18nTags.statusCard.submit_post]: 'Tröt',
[I18nTags.statusCard.show_content]: 'Mehr anzeigen',
[I18nTags.statusCard.hide_content]: 'Weniger anzeigen',
[I18nTags.statusCard.mute_status]: 'Stummschalten',
[I18nTags.statusCard.delete_status]: 'Löschen',
[I18nTags.statusCard.delete_status_confirm]: 'Möchtest du diesen Beitrag löschen?',
[I18nTags.statusCard.do_delete_status_btn]: 'Löschen',
[I18nTags.statusCard.cancel_delete_status_btn]: 'Abbrechen',
[I18nTags.statusCard.originally_shared_by]: 'Ursprünglich geteilt von {displayName}<span class="at-name">@{atName}</span>',
[I18nTags.statusCard.sensitive_media_alert]: 'Ausgeblendeter Inhalt <br/> Tippen zum Anzeigen',
[I18nTags.statusCard.change_visibility]: 'Sichtbarkeit ändern',
[I18nTags.statusCard.add_photos]: 'Fotos hinzufügen'
}
const drawer = {
[I18nTags.drawer.home]: 'Übersicht',
[I18nTags.drawer.public]: 'Öffentlich',
[I18nTags.drawer.tag]: 'Tag',
[I18nTags.drawer.local]: 'Lokal',
[I18nTags.drawer.profile]: 'Profil',
[I18nTags.drawer.settings]: 'Einstellungen',
[I18nTags.drawer.logout]: 'Abmelden',
[I18nTags.drawer.do_logout_message_confirm]: 'Möchtest du abmelden?',
[I18nTags.drawer.do_logout_message_yes]: 'Ja',
[I18nTags.drawer.do_logout_message_no]: 'Nein',
[I18nTags.drawer.toHostInstance]: 'Aktuelle Instanzseite öffnen',
[I18nTags.drawer.search_input_placeholder]: 'Suchen',
[I18nTags.drawer.search_result_people_label]: 'Leute',
[I18nTags.drawer.search_result_hashtag_label]: 'Hashtag'
}
const settings = {
[I18nTags.settings.general_label]: 'Allgemeines',
[I18nTags.settings.choose_theme]: 'Themen',
[I18nTags.settings.export_theme_color_set]: 'Exportieren',
[I18nTags.settings.import_theme_color_set]: 'Importieren',
[I18nTags.settings.edit_theme_color_set]: 'Bearbeiten',
[I18nTags.settings.delete_theme_color_set]: 'Löschen',
[I18nTags.settings.choose_language]: 'Sprache',
[I18nTags.settings.use_multi_line_mode]: 'Mehrspaltiger Layout-Mode verwenden',
[I18nTags.settings.maximum_number_of_columns_in_multi_line_mode]: 'Maximale Anzahl der Spalten im mehrspaltigen Layout-Mode',
[I18nTags.settings.show_sensitive_media_files]: 'Sensible Medien immer anzeigen',
[I18nTags.settings.auto_expand_spoiler_text]: 'Ausgeblendete Texte immer automatisch expandieren',
[I18nTags.settings.auto_load_new_status]: 'Neue Beiträge immer automatisch laden',
[I18nTags.settings.post_privacy]: 'Beitragssichtbarkeit',
[I18nTags.settings.post_media_as_sensitive]: 'Medien immer als sensibel markieren',
[I18nTags.settings.only_mention_target_user]: 'Nur für erwähnte Freunden sichtbar',
[I18nTags.settings.stream_label]: 'Stream',
[I18nTags.settings.media_label]: 'Medien',
[I18nTags.settings.personality_label]: 'Individualität',
[I18nTags.settings.publishing_label]: 'Veröffentlichung',
[I18nTags.settings.web_label]: 'Web',
[I18nTags.settings.changes_successfully_saved]: 'Änderungen gespeichert!'
}
const timeLines = {
[I18nTags.timeLines.no_load_more_status_notice]: 'Das ist alles!',
[I18nTags.timeLines.new_message_notice]: '{count} neue Beitrag | {count} neue Beiträge',
[I18nTags.timeLines.whats_new_with_you]: 'Gibt\'s etwas Neues?'
}
const postStatusDialog = {
[I18nTags.postStatusDialog.do_discard_message_confirm]: 'Möchtest du diesen Beitrag verwerfen?',
[I18nTags.postStatusDialog.do_keep_message]: 'Behalten',
[I18nTags.postStatusDialog.do_discard_message]: 'Verwerfen',
[I18nTags.postStatusDialog.text_character_limit_exceed]: 'Buchstabenbegrenzung von 500 überschritten'
}
const notifications = {
[I18nTags.notifications.someone_followed_you]: 'folgt dir',
[I18nTags.notifications.mentioned_you]: 'hat dich erwähnt',
[I18nTags.notifications.favourited_your_status]:'gefällt deinen Status',
[I18nTags.notifications.boosted_your_status]: 'hat deinen Beitrag erneut geteilt'
}
export default {
...oauth,
...common,
...statusCard,
...timeLines,
...drawer,
...settings,
...postStatusDialog,
...notifications
}
================================================
FILE: src/i18n/en.ts
================================================
import { I18nTags } from '@/constant'
const oauth = {
[I18nTags.oauth.form_brand]: 'Cuckoo Plus',
[I18nTags.oauth.login_hint]: 'Authorize Login',
[I18nTags.oauth.server_input_label]: 'Mastodon URL',
[I18nTags.oauth.please_input_server_url]: 'please input Mastodon URL',
[I18nTags.oauth.please_input_correct_server_url]: 'check your Mastodon URL',
[I18nTags.oauth.register_app_error_message]: 'Something went wrong! please check your Mastodon URL again',
[I18nTags.oauth.confirm_input]: 'CONFIRM'
}
const common = {
[I18nTags.common.status_visibility_public]: 'Public',
[I18nTags.common.status_visibility_unlisted]: 'Unlisted',
[I18nTags.common.status_visibility_private]: 'Followers-only',
[I18nTags.common.status_visibility_direct]: 'Direct',
[I18nTags.common.status_visibility_public_desc]: 'Post to public timelines',
[I18nTags.common.status_visibility_unlisted_desc]: 'Do not post to public timelines',
[I18nTags.common.status_visibility_private_desc]: 'Post to followers only',
[I18nTags.common.status_visibility_direct_desc]: 'Post to mentioned users only',
[I18nTags.com
gitextract_m6ecmic0/ ├── .editorconfig ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── deploy.sh ├── light-sail-cuckoo-plus.pem.enc ├── package.json ├── public/ │ ├── index.html │ ├── manifest.json │ └── sw.js ├── server.js ├── src/ │ ├── App.vue │ ├── api/ │ │ ├── accounts.ts │ │ ├── apps.ts │ │ ├── index.ts │ │ ├── instances.ts │ │ ├── lists.ts │ │ ├── media.ts │ │ ├── notifications.ts │ │ ├── oauth.ts │ │ ├── search.ts │ │ ├── statuses.ts │ │ ├── streaming.ts │ │ └── timelines.ts │ ├── components/ │ │ ├── Drawer/ │ │ │ ├── PeopleResultCard.vue │ │ │ ├── Search.vue │ │ │ └── index.vue │ │ ├── Header.vue │ │ ├── Input.vue │ │ ├── Notifications/ │ │ │ ├── Card.vue │ │ │ └── index.vue │ │ ├── PostStatusDialog.vue │ │ ├── StatusCard/ │ │ │ ├── CardHeader.vue │ │ │ ├── FullActionBar.vue │ │ │ ├── FullReplyListItem.vue │ │ │ ├── LinkPreviewPanel.vue │ │ │ ├── MediaPanel.vue │ │ │ ├── PlaceHolderMediaItem.vue │ │ │ ├── SimpleActionBar.vue │ │ │ └── index.vue │ │ ├── ThemeEditPanel.vue │ │ └── VisibilitySelectPopOver.vue │ ├── constant/ │ │ ├── i18n.ts │ │ ├── index.ts │ │ └── routers.ts │ ├── directives.ts │ ├── formatter.spec.ts │ ├── formatter.ts │ ├── i18n/ │ │ ├── compare │ │ ├── de.ts │ │ ├── en.ts │ │ ├── index.ts │ │ ├── ja.ts │ │ ├── zh-cn.ts │ │ ├── zh-hk.ts │ │ └── zh-tw.ts │ ├── index.ts │ ├── interface/ │ │ ├── definition/ │ │ │ ├── vue-extend.d.ts │ │ │ └── vue-shims.d.ts │ │ ├── entities.ts │ │ ├── index.ts │ │ └── store.ts │ ├── pages/ │ │ ├── Accounts/ │ │ │ ├── AccountHeader.vue │ │ │ └── index.vue │ │ ├── OAuth.vue │ │ ├── Settings.vue │ │ ├── Statuses.vue │ │ └── Timelines/ │ │ ├── NewStatusNoticeButton.vue │ │ ├── PostStatusStampCard.vue │ │ └── index.vue │ ├── router/ │ │ └── index.ts │ ├── store/ │ │ ├── actions/ │ │ │ ├── accounts.ts │ │ │ ├── appstatus.ts │ │ │ ├── index.ts │ │ │ ├── notifications.ts │ │ │ ├── relationships.ts │ │ │ ├── statuses.ts │ │ │ └── timelines.ts │ │ ├── getters/ │ │ │ └── index.ts │ │ ├── index.ts │ │ └── mutations/ │ │ ├── appstatus.ts │ │ ├── index.ts │ │ ├── notifications.ts │ │ └── timelines.ts │ ├── themes/ │ │ ├── basecolor.ts │ │ ├── index.ts │ │ ├── presets/ │ │ │ ├── cuckoohub.ts │ │ │ ├── dark.ts │ │ │ ├── googleplus.ts │ │ │ └── greenlight.ts │ │ └── stylepattern.ts │ └── util.ts ├── tsconfig.json └── webpack.config.js
SYMBOL INDEX (223 symbols across 35 files)
FILE: public/sw.js
constant CACHE (line 2) | const CACHE = version + ':CP'
class SW (line 22) | class SW {
method constructor (line 24) | constructor () {
method initInstallEventListener (line 30) | initInstallEventListener () {
method installFiles (line 36) | installFiles () {
method initActivateEventListener (line 42) | initActivateEventListener () {
method clearOldCaches (line 49) | clearOldCaches () {
method initFetchEventListener (line 55) | initFetchEventListener () {
method isCacheFilePath (line 81) | isCacheFilePath (url) {
FILE: src/api/accounts.ts
type updateAccountFormData (line 5) | interface updateAccountFormData {
function fetchAccountInfoById (line 25) | async function fetchAccountInfoById () {
function fetchCurrentUserAccountInfo (line 29) | async function fetchCurrentUserAccountInfo (): Promise<{ data: mastodone...
function updateUserAccountInfo (line 33) | async function updateUserAccountInfo (formData: updateAccountFormData): ...
function fetchRelationships (line 37) | async function fetchRelationships (idList: Array<string>) {
function followAccountById (line 45) | async function followAccountById (id: string) {
function unFollowAccountById (line 49) | async function unFollowAccountById (id: string) {
FILE: src/api/apps.ts
type registerApplicationFormData (line 9) | interface registerApplicationFormData {
type registerApplicationReturnData (line 21) | interface registerApplicationReturnData {
function registerApplication (line 34) | async function registerApplication (): Promise<Apps.registerApplicationR...
FILE: src/api/instances.ts
function getCustomEmojis (line 5) | async function getCustomEmojis (): Promise<{ data: Array<mastodonentitie...
FILE: src/api/lists.ts
function receiveLists (line 4) | async function receiveLists () {
FILE: src/api/media.ts
function postMediaFile (line 5) | async function postMediaFile (formData): Promise<{ data: mastodonentitie...
FILE: src/api/notifications.ts
type getNotificationsQueryParams (line 5) | interface getNotificationsQueryParams {
function getNotifications (line 16) | async function getNotifications(queryParams: getNotificationsQueryParams...
FILE: src/api/oauth.ts
type fetchOAuthTokenReturnData (line 6) | interface fetchOAuthTokenReturnData extends HttpResponse {
function fetchOAuthToken (line 12) | async function fetchOAuthToken (): Promise<fetchOAuthTokenReturnData> {
FILE: src/api/search.ts
function getSearchResults (line 11) | async function getSearchResults (q: string, resolve: boolean = false): P...
function abortSearch (line 23) | function abortSearch () {
FILE: src/api/statuses.ts
function getStatusById (line 6) | async function getStatusById (id: string): Promise<{ data: mastodonentit...
type postStatusFormData (line 10) | interface postStatusFormData {
function postStatus (line 27) | async function postStatus (formData: postStatusFormData): Promise<{ data...
function getStatusContextById (line 47) | async function getStatusContextById (id: string): Promise<{ data: mastod...
function getReBloggedAccountsById (line 51) | async function getReBloggedAccountsById (id: string): Promise<{ data: Ar...
function getFavouritedAccountsById (line 55) | async function getFavouritedAccountsById (id: string): Promise<{ data: A...
function favouriteStatusById (line 59) | async function favouriteStatusById (id: string): Promise<{ data: mastodo...
function unFavouriteStatusById (line 63) | async function unFavouriteStatusById (id: string): Promise<{ data: masto...
function reblogStatusById (line 67) | async function reblogStatusById (id: string): Promise<{ data: mastodonen...
function unReblogStatusById (line 71) | async function unReblogStatusById (id: string): Promise<{ data: mastodon...
function deleteStatusById (line 75) | async function deleteStatusById (id: string) {
function muteStatusById (line 79) | async function muteStatusById (id: string) {
function unMuteStatusById (line 83) | async function unMuteStatusById (id: string) {
function getStatusCardInfoById (line 87) | async function getStatusCardInfoById (id: string): Promise<{ data: masto...
FILE: src/api/streaming.ts
class NotificationHandler (line 8) | class NotificationHandler {
method emit (line 9) | public emit (newNotification: mastodonentities.Notification) {
method emitStatusOperateNotification (line 34) | private emitStatusOperateNotification (newNotification: mastodonentiti...
method getFromName (line 54) | private getFromName (newNotification: mastodonentities.Notification): ...
method getImageUrl (line 60) | private getImageUrl (newNotification: mastodonentities.Notification): ...
method routeToTargetStatus (line 64) | private async routeToTargetStatus (newNotification: mastodonentities.N...
method routeToTargetAccount (line 75) | private routeToTargetAccount () {
class Streaming (line 82) | class Streaming {
method createWsUrl (line 90) | private createWsUrl (streamName: string) {
method openUserConnection (line 94) | public openUserConnection () {
method openLocalConnection (line 102) | public openLocalConnection () {
method openPublicConnection (line 110) | public openPublicConnection () {
method closeConnection (line 118) | public closeConnection (timeLineType: string) {
method initEventListener (line 128) | private initEventListener (targetWs: WebSocket, timeLineType, hashName...
method updateStatus (line 150) | private updateStatus (newStatus: mastodonentities.Status, timeLineType...
method deleteStatus (line 169) | private deleteStatus (statusId: string) {
method emitNotification (line 179) | private emitNotification (newNotification: mastodonentities.Notificati...
FILE: src/api/timelines.ts
function getTimeLineStatuses (line 11) | async function getTimeLineStatuses ({ timeLineType = '', maxId = '', sin...
FILE: src/constant/index.ts
constant TITLE (line 60) | const TITLE = 'Cuckoo+'
FILE: src/directives.ts
method update (line 30) | update (el: HTMLDivElement, binding, vNode) {
type MasonryItem (line 45) | interface MasonryItem {
type MasonryContainer (line 50) | interface MasonryContainer extends HTMLDivElement {
method inserted (line 71) | inserted (el: MasonryContainer) {
method inserted (line 117) | inserted (el: HTMLDivElement) {
FILE: src/formatter.ts
class Formatter (line 3) | class Formatter {
method constructor (line 18) | constructor (customEmojis: Array<mastodonentities.Emoji> = []) {
method insertSomething (line 24) | private insertSomething (regex: RegExp, fragment: string, tag: string) {
method insertDels (line 38) | public insertDels (text: string): string {
method insertBolds (line 42) | public insertBolds (text: string): string {
method insetItalic (line 46) | public insetItalic (text) {
method insertCustomEmojis (line 50) | private insertCustomEmojis (text: string): string {
method updateCustomEmojiMap (line 62) | public updateCustomEmojiMap (customEmojis: Array<mastodonentities.Emoj...
method format (line 68) | public format (text: string, customEmojis: Array<mastodonentities.Emoj...
FILE: src/index.ts
method install (line 20) | install (Vue) {
method render (line 77) | render(h) {
FILE: src/interface/definition/vue-extend.d.ts
type routerInfo (line 3) | interface routerInfo { path: string, name: string }
type Vue (line 6) | interface Vue {
FILE: src/interface/entities.ts
type Application (line 3) | interface Application {
type Account (line 7) | interface Account {
type AuthenticatedAccount (line 48) | interface AuthenticatedAccount extends Account {
type Status (line 61) | interface Status {
type Context (line 118) | interface Context {
type Emoji (line 123) | interface Emoji {
type Attachment (line 127) | interface Attachment {
type ImageSizeMetaItem (line 153) | interface ImageSizeMetaItem {
type ImageMeta (line 160) | interface ImageMeta {
type GifvMeta (line 166) | interface GifvMeta extends ImageSizeMetaItem {
type Mention (line 180) | interface Mention {
type Tag (line 191) | interface Tag {
type Notification (line 195) | interface Notification {
type NotificationType (line 208) | type NotificationType = "mention" | "reblog" | "favourite" | "follow"
type SearchResults (line 210) | interface SearchResults {
type Emoji (line 216) | interface Emoji {
type Relationship (line 227) | interface Relationship {
type Card (line 250) | interface Card {
FILE: src/interface/store.ts
type stateInfo (line 5) | interface stateInfo {
type OAuthInfo (line 85) | interface OAuthInfo {
FILE: src/router/index.ts
method beforeEnter (line 87) | beforeEnter (to, from, next) {
function checkShouldRegisterApplication (line 111) | function checkShouldRegisterApplication (to, from): boolean {
method startLoading (line 146) | private startLoading (process: string) {
method endLoading (line 151) | private endLoading () {
method initFetchNotifications (line 161) | public initFetchNotifications () {
method initStreamConnection (line 168) | public initStreamConnection () {
method initLocalStreamConnection (line 175) | public initLocalStreamConnection () {
method destroyLocalStreamConnection (line 182) | public destroyLocalStreamConnection () {
method initPublicStreamConnection (line 189) | public initPublicStreamConnection () {
method destroyPublicStreamConnection (line 196) | public destroyPublicStreamConnection () {
method updateCurrentUserAccount (line 204) | public async updateCurrentUserAccount () {
method updateOAuthAccessToken (line 219) | public async updateOAuthAccessToken () {
method updateCustomEmojis (line 229) | public async updateCustomEmojis () {
method beforeEachRoute (line 250) | async beforeEachRoute (to, from, next) {
method beforeDefaultTimeLines (line 258) | beforeDefaultTimeLines (to: Route, from, next) {
method beforeNeedOAuthRoutes (line 268) | async beforeNeedOAuthRoutes (to, from, next) {
method beforeHomeTimeLine (line 295) | beforeHomeTimeLine (to, from, next) {
method beforeLocalTimeLine (line 303) | beforeLocalTimeLine (to, from, next) {
method afterLocalTimeLine (line 311) | afterLocalTimeLine (to, from, next) {
method beforePublicTimeLine (line 319) | beforePublicTimeLine (to, from, next) {
method afterPublicTimeLine (line 327) | afterPublicTimeLine (to, from, next) {
FILE: src/store/actions/accounts.ts
method followAccountById (line 5) | async followAccountById ({ commit }, id: string) {
method unFollowAccountById (line 14) | async unFollowAccountById ({ commit }, id: string) {
FILE: src/store/actions/appstatus.ts
method loadStreamStatusesPool (line 6) | loadStreamStatusesPool ({ commit, state }, { timeLineType, hashName }) {
method updatePostPrivacy (line 17) | async updatePostPrivacy ({ commit }, newPrivacy: string) {
method updatePostMediaAsSensitiveMode (line 28) | async updatePostMediaAsSensitiveMode ({ commit }, newSensitiveMode: bool...
FILE: src/store/actions/index.ts
method updateCurrentUserAccount (line 18) | async updateCurrentUserAccount ({ commit }) {
method updateCustomEmojis (line 33) | async updateCustomEmojis ({ commit }) {
FILE: src/store/actions/notifications.ts
method updateNotifications (line 7) | async updateNotifications ({ commit, state, dispatch }, { isLoadMore, is...
FILE: src/store/actions/relationships.ts
method updateRelationships (line 5) | async updateRelationships ({ commit }, { idList }: { idList: Array<strin...
FILE: src/store/actions/statuses.ts
type postStatusFormData (line 4) | interface postStatusFormData {
method fetchStatusById (line 22) | async fetchStatusById ({ commit, dispatch }, statusId: string) {
method updateFavouriteStatusById (line 33) | async updateFavouriteStatusById ({ commit }, { favourited, mainStatusId,...
method updateReblogStatusById (line 48) | async updateReblogStatusById ({ commit }, { reblogged, mainStatusId, tar...
method updateContextMap (line 62) | async updateContextMap ({ commit, dispatch }, statusId: string) {
method updateCardMap (line 84) | async updateCardMap (store, statusId: string) {
method postStatus (line 99) | async postStatus ({ commit, dispatch }, { formData, mainStatusId }: {
method deleteStatus (line 126) | async deleteStatus ({ commit }, { statusId }) {
FILE: src/store/actions/timelines.ts
method updateTimeLineStatuses (line 7) | async updateTimeLineStatuses ({ commit, dispatch, state }, { timeLineTyp...
FILE: src/store/getters/index.ts
method getAccountDisplayName (line 6) | getAccountDisplayName () {
method getAccountAtName (line 10) | getAccountAtName () {
method getRootStatuses (line 16) | getRootStatuses (state: cuckoostore.stateInfo) {
method isOAuthUser (line 42) | isOAuthUser (state: cuckoostore.stateInfo) {
method isMobileMode (line 46) | isMobileMode (state: cuckoostore.stateInfo) {
method shouldDialogFullScreen (line 50) | shouldDialogFullScreen (state: cuckoostore.stateInfo) {
FILE: src/store/index.ts
function getLocalSetting (line 11) | function getLocalSetting (tag, defaultValue) {
FILE: src/store/mutations/appstatus.ts
method updateDrawerOpenStatus (line 8) | updateDrawerOpenStatus (state: cuckoostore.stateInfo, isDrawerOpened: bo...
method updateNotificationsPanelStatus (line 12) | updateNotificationsPanelStatus (state: cuckoostore.stateInfo, isNotifica...
method updateUnreadNotificationCount (line 16) | updateUnreadNotificationCount (state: cuckoostore.stateInfo, count: numb...
method updateDocumentWidth (line 20) | updateDocumentWidth (state: cuckoostore.stateInfo) {
method updateTheme (line 24) | updateTheme (state: cuckoostore.stateInfo, newThemeName: string) {
method updateTags (line 29) | updateTags (state: cuckoostore.stateInfo, newTags: Array<string>) {
method updateMultiLineMode (line 34) | updateMultiLineMode (state: cuckoostore.stateInfo, newMode: boolean) {
method updateShowSensitiveContentMode (line 39) | updateShowSensitiveContentMode (state: cuckoostore.stateInfo, newMode: b...
method updateRealTimeLoadStatusMode (line 44) | updateRealTimeLoadStatusMode (state: cuckoostore.stateInfo, newMode: boo...
method updateLocale (line 49) | updateLocale (state: cuckoostore.stateInfo, newLocale: string) {
method updateMuteStatusList (line 54) | updateMuteStatusList (state: cuckoostore.stateInfo, statusId: string) {
method updateMuteUserList (line 60) | updateMuteUserList (state: cuckoostore.stateInfo, userId: string) {
method unShiftStreamStatusesPool (line 66) | unShiftStreamStatusesPool (state: cuckoostore.stateInfo, { newStatusIdLi...
method clearStreamStatusesPool (line 75) | clearStreamStatusesPool (state: cuckoostore.stateInfo, { timeLineType, h...
method updatePostPrivacy (line 80) | updatePostPrivacy (state: cuckoostore.stateInfo, newPostPrivacy: string) {
method updatePostMediaAsSensitiveMode (line 85) | updatePostMediaAsSensitiveMode (state: cuckoostore.stateInfo, newMode: b...
method updateOnlyMentionTargetUserMode (line 90) | updateOnlyMentionTargetUserMode (state: cuckoostore.stateInfo, newMode: ...
method updateMaximumNumberOfColumnsInMultiLineMode (line 95) | updateMaximumNumberOfColumnsInMultiLineMode (state: cuckoostore.stateInf...
method updateAutoExpandSpoilerTextMode (line 100) | updateAutoExpandSpoilerTextMode (state: cuckoostore.stateInfo, newMode: ...
method updateIsEditingThemeMode (line 105) | updateIsEditingThemeMode (state: cuckoostore.stateInfo, newMode: boolean) {
method updateShouldShowThemeEditPanel (line 110) | updateShouldShowThemeEditPanel (state: cuckoostore.stateInfo, show: bool...
FILE: src/store/mutations/index.ts
function formatStatusContent (line 8) | function formatStatusContent (status: mastodonentities.Status) {
method clearAllOAuthInfo (line 14) | clearAllOAuthInfo (state: cuckoostore.stateInfo) {
method updateClientInfo (line 23) | updateClientInfo (state: cuckoostore.stateInfo, { clientId, clientSecret...
method updateOAuthCode (line 31) | updateOAuthCode (state: cuckoostore.stateInfo, code: string) {
method updateOAuthAccessToken (line 37) | updateOAuthAccessToken (state: cuckoostore.stateInfo, accessToken: strin...
method updateStatusMap (line 45) | updateStatusMap (state: cuckoostore.stateInfo, newStatusMap) {
method removeStatusFromStatusMapById (line 69) | removeStatusFromStatusMapById (state: cuckoostore.stateInfo, statusId: s...
method updateFavouriteStatusById (line 73) | updateFavouriteStatusById (state: cuckoostore.stateInfo, { favourited, m...
method updateReblogStatusById (line 91) | updateReblogStatusById (state: cuckoostore.stateInfo, { reblogged, mainS...
method updateMastodonServerUri (line 111) | updateMastodonServerUri (state: cuckoostore.stateInfo, mastodonServerUri...
method updateCurrentUserAccount (line 117) | updateCurrentUserAccount (state: cuckoostore.stateInfo, currentUserAccou...
method updateCustomEmojis (line 125) | updateCustomEmojis (state: cuckoostore.stateInfo, customEmojis: Array<ma...
method updateContextMap (line 131) | updateContextMap (state: cuckoostore.stateInfo, newContextMap) {
method updateCardMap (line 137) | updateCardMap (state: cuckoostore.stateInfo, newCardMap) {
FILE: src/store/mutations/notifications.ts
method unShiftNotification (line 6) | unShiftNotification (state: cuckoostore.stateInfo, newNotifications: Arr...
method pushNotifications (line 18) | pushNotifications (state: cuckoostore.stateInfo, newNotifications: Array...
method updateRelationships (line 22) | updateRelationships (state: cuckoostore.stateInfo, newRelationships: { [...
FILE: src/store/mutations/timelines.ts
method setTimeLineStatuses (line 7) | setTimeLineStatuses (state: cuckoostore.stateInfo, { newStatusIdList, ti...
method pushTimeLineStatuses (line 17) | pushTimeLineStatuses (state: cuckoostore.stateInfo, { newStatusIdList, t...
method unShiftTimeLineStatuses (line 33) | unShiftTimeLineStatuses (state: cuckoostore.stateInfo, { newStatusIdList...
method deleteStatusFromTimeLine (line 49) | deleteStatusFromTimeLine (state: cuckoostore.stateInfo, statusId: string) {
FILE: src/themes/index.ts
class ThemeManager (line 35) | class ThemeManager {
method themeInfo (line 37) | public get themeInfo () {
method getThemeStyleElem (line 43) | private getThemeStyleElem (): HTMLStyleElement {
method setFavIconByThemeName (line 56) | private setFavIconByThemeName (themeName: string) {
method setThemeColorByThemeName (line 67) | private setThemeColorByThemeName (themeName: string) {
method setThemeCssByThemeName (line 73) | private setThemeCssByThemeName (themeName: string) {
method addCustomThemeInfo (line 89) | private addCustomThemeInfo (themeColorSet, themeName) {
method deleteCustomThemeInfo (line 99) | private deleteCustomThemeInfo (themeName) {
method updateLocalStorageData (line 104) | private updateLocalStorageData () {
method getThemeInfoByThemeName (line 114) | public getThemeInfoByThemeName (themeName: string) {
method getThemeOptionsList (line 120) | public getThemeOptionsList () {
method getCustomThemeOptionsList (line 126) | public getCustomThemeOptionsList () {
method setTheme (line 132) | public setTheme (themeName: string) {
method exportTheme (line 141) | public exportTheme (themeName: string) {
method importTheme (line 146) | public importTheme (themeColorSet, themeName: string) {
method deleteTheme (line 150) | public deleteTheme (themeName: string) {
method setTempThemeByColorSet (line 154) | public setTempThemeByColorSet (colorSet) {
FILE: src/util.ts
function patchApiUri (line 9) | function patchApiUri (uri: string): string {
function generateUniqueKey (line 14) | function generateUniqueKey () {
function isBaseTimeLine (line 25) | function isBaseTimeLine (timeLineType: string): boolean {
function getTimeLineTypeAndHashName (line 29) | function getTimeLineTypeAndHashName (route: Route) {
function getTargetStatusesList (line 46) | function getTargetStatusesList (listMap, timeLineType, hashName) {
function getVisibilityDescInfo (line 75) | function getVisibilityDescInfo (visibilityType: string) {
function prepareRootStatus (line 79) | async function prepareRootStatus (status: mastodonentities.Status) {
function formatHtml (line 104) | function formatHtml(html: string, options: { externalEmojis } = { extern...
function formatAccountDisplayName (line 124) | function formatAccountDisplayName (account: mastodonentities.Account) {
function extractText (line 128) | function extractText(html: string): string {
function resetImageFileSizeForUpload (line 143) | async function resetImageFileSizeForUpload (file: File) {
function walkTextNodes (line 170) | function walkTextNodes(node, textNodeHandler) {
function easeInOutQuad (line 183) | function easeInOutQuad (t, b, c, d) {
function animatedScrollTo (line 192) | function animatedScrollTo (element: HTMLElement, to: number, duration: n...
function getNetEaseMusicFrameLinkFromContentLink (line 221) | function getNetEaseMusicFrameLinkFromContentLink (link: string): string ...
function getYoutubeVideoFrameLinkFromContentLink (line 254) | function getYoutubeVideoFrameLinkFromContentLink (link: string): string ...
method on (line 292) | on (eventName: string, eventListener: Function, coexistWithOtherListener...
method off (line 309) | off (eventName: string, eventListener: Function) {
method initDocumentGlobalEvent (line 323) | private initDocumentGlobalEvent (eventName: string) {
Condensed preview — 95 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (363K chars).
[
{
"path": ".editorconfig",
"chars": 72,
"preview": "root = true\n\n[*]\nend_of_line = lf\n\n[*.{js,ts,vue,json}]\nindent_size = 2\n"
},
{
"path": ".gitignore",
"chars": 59,
"preview": ".idea\npackage-lock.json\nnode_modules\npublic/dist\n.DS_store\n"
},
{
"path": ".travis.yml",
"chars": 452,
"preview": "language: node_js\nnode_js:\n- 10.15.3\naddons:\n ssh_known_hosts: 52.76.67.104\nbefore_deploy:\n- openssl aes-256-cbc -K $en"
},
{
"path": "LICENSE",
"chars": 1066,
"preview": "MIT License\n\nCopyright (c) 2018 Morse_Guo\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\n"
},
{
"path": "README.md",
"chars": 494,
"preview": "# Cuckoo.Plus [](https://travis-ci.com/Nan"
},
{
"path": "deploy.sh",
"chars": 101,
"preview": "npm run build\nscp -i \"light-sail-cuckoo-plus.pem\" -r public ubuntu@52.76.67.104:projects/Cuckoo.Plus/"
},
{
"path": "package.json",
"chars": 2613,
"preview": "{\n \"name\": \"cuckoo.plus\",\n \"version\": \"0.3.27\",\n \"description\": \"A third-party client for mastodon\",\n \"scripts\": {\n "
},
{
"path": "public/index.html",
"chars": 2440,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <!-- Global site tag (gtag.js) - Google Analytics -->\n <script async src=\"htt"
},
{
"path": "public/manifest.json",
"chars": 866,
"preview": "{\n \"name\" : \"Cuckoo Social\",\n \"short_name\" : \"Cuckoo+\",\n \"start_url\" : \"/\",\n \"display\" "
},
{
"path": "public/sw.js",
"chars": 2475,
"preview": "const version = '0.3.27'\nconst CACHE = version + ':CP'\nconst cacheFilePaths = [\n '/',\n '/manifest.json',\n '/dist/bund"
},
{
"path": "server.js",
"chars": 602,
"preview": "const express = require('express')\nconst http = require('http')\nconst https = require('https')\nconst fs = require('fs')\n"
},
{
"path": "src/App.vue",
"chars": 3887,
"preview": "<template>\n <div id=\"app\">\n <cuckoo-plus-header v-if=\"!$route.meta.hideHeader\"/>\n <cuckoo-plus-drawer v-if=\"!$rou"
},
{
"path": "src/api/accounts.ts",
"chars": 1720,
"preview": "import Vue from 'vue'\nimport { mastodonentities } from '@/interface'\nimport { patchApiUri } from '@/util'\n\ninterface upd"
},
{
"path": "src/api/apps.ts",
"chars": 1139,
"preview": "import Vue from 'vue'\nimport { patchApiUri } from '@/util'\n\nconst clientName = 'Cuckoo.Plus'\nconst scopes = 'read write "
},
{
"path": "src/api/index.ts",
"chars": 546,
"preview": "import * as apps from './apps'\nimport * as oauth from './oauth'\nimport * as accounts from './accounts'\nimport * as lists"
},
{
"path": "src/api/instances.ts",
"chars": 292,
"preview": "import Vue from 'vue'\nimport { mastodonentities } from '@/interface'\nimport { patchApiUri } from '@/util'\n\nasync functio"
},
{
"path": "src/api/lists.ts",
"chars": 177,
"preview": "import Vue from 'vue'\nimport { patchApiUri } from '@/util'\n\nasync function receiveLists () {\n return Vue.http.get(patch"
},
{
"path": "src/api/media.ts",
"chars": 297,
"preview": "import Vue from 'vue'\nimport { mastodonentities } from '@/interface'\nimport { patchApiUri } from '@/util'\n\nasync functio"
},
{
"path": "src/api/notifications.ts",
"chars": 870,
"preview": "import Vue from 'vue'\nimport { mastodonentities } from '@/interface'\nimport { patchApiUri } from '@/util'\n\ninterface get"
},
{
"path": "src/api/oauth.ts",
"chars": 655,
"preview": "import Vue from 'vue'\nimport store from '@/store'\nimport { patchApiUri } from '@/util'\nimport HttpResponse = vuejs.HttpR"
},
{
"path": "src/api/search.ts",
"chars": 684,
"preview": "import Vue from 'vue'\nimport { mastodonentities } from '@/interface'\nimport { patchApiUri } from '@/util'\n\nlet preSearch"
},
{
"path": "src/api/statuses.ts",
"chars": 3647,
"preview": "import Vue from 'vue'\nimport { mastodonentities } from '@/interface'\nimport { patchApiUri, generateUniqueKey } from '@/u"
},
{
"path": "src/api/streaming.ts",
"chars": 6357,
"preview": "import store from '@/store'\nimport { StreamingEventTypes, TimeLineTypes, NotificationTypes, RoutersInfo, I18nTags } from"
},
{
"path": "src/api/timelines.ts",
"chars": 1244,
"preview": "import Vue from 'vue'\nimport { patchApiUri, isBaseTimeLine } from '@/util'\nimport { TimeLineTypes } from '@/constant'\nim"
},
{
"path": "src/components/Drawer/PeopleResultCard.vue",
"chars": 3072,
"preview": "<template>\n <mu-list-item class=\"people-result-card\" avatar :ripple=\"false\" v-loading=\"isLoading\" data-mu-loading-size="
},
{
"path": "src/components/Drawer/Search.vue",
"chars": 5708,
"preview": "<template>\n <div class=\"search-area-container\">\n <div class=\"search-bar\">\n <mu-icon value=\"search\" style=\"margi"
},
{
"path": "src/components/Drawer/index.vue",
"chars": 9100,
"preview": "<template>\n <mu-drawer class=\"cuckoo-drawer default-theme-bg-color primary-read-text-color\" :open.sync=\"appStatus.isDra"
},
{
"path": "src/components/Header.vue",
"chars": 6991,
"preview": "<template>\n <div class=\"cuckoo-header-container\">\n <mu-appbar class=\"header\" :class=\"shouldUseSecondaryThemeHeader &"
},
{
"path": "src/components/Input.vue",
"chars": 12360,
"preview": "<template>\n <div class=\"cuckoo-input-container\">\n\n <textarea v-show=\"shouldShowSpoilerTextInputArea\" ref=\"spoilerTex"
},
{
"path": "src/components/Notifications/Card.vue",
"chars": 5994,
"preview": "<template>\n <mu-list-item :style=\"notificationCardStyle\" v-loading=\"isLoading\"\n @click=\"onNotificationCa"
},
{
"path": "src/components/Notifications/index.vue",
"chars": 5720,
"preview": "<template>\n <div class=\"notification-panel-container base-theme-bg-color\" :style=\"isLoadingTargetStatus ? { overflow: '"
},
{
"path": "src/components/PostStatusDialog.vue",
"chars": 14381,
"preview": "<template>\n <mu-dialog dialog-class=\"post-status-dialog-container\"\n :open.sync=\"isDialogOpening\" overlay-co"
},
{
"path": "src/components/StatusCard/CardHeader.vue",
"chars": 6726,
"preview": "<template>\n <mu-card-header class=\"mu-card-header\" ref=\"cardHeader\"\n @mouseover=\"shouldShowHeaderActio"
},
{
"path": "src/components/StatusCard/FullActionBar.vue",
"chars": 10145,
"preview": "<template>\n <div class=\"full-action-bar\">\n <div class=\"reply-input-area\">\n <mu-avatar class=\"current-user-avata"
},
{
"path": "src/components/StatusCard/FullReplyListItem.vue",
"chars": 11116,
"preview": "<template>\n <div class=\"full-reply-list-item\" v-loading=\"isListItemLoading\" @mouseover=\"onItemMouseOver\" @mouseout=\"onI"
},
{
"path": "src/components/StatusCard/LinkPreviewPanel.vue",
"chars": 2475,
"preview": "<template>\n <div class=\"link-preview-panel-container\" @click=\"onLinkPreviewPanelClick\">\n <div class=\"content-area\">\n"
},
{
"path": "src/components/StatusCard/MediaPanel.vue",
"chars": 10486,
"preview": "<template>\n <div class=\"media-panel-container\" v-if=\"combinedMediaList.length > 0\">\n <div class=\"media-area\" ref=\"me"
},
{
"path": "src/components/StatusCard/PlaceHolderMediaItem.vue",
"chars": 2621,
"preview": "<template>\n <div class=\"placeholder-media-item-container\" :style=\"placeHolderItemStyle\">\n\n <div class=\"placeholder-a"
},
{
"path": "src/components/StatusCard/SimpleActionBar.vue",
"chars": 4151,
"preview": "<template>\n <div class=\"simple-action-bar\">\n\n <div class=\"left-area\">\n <mu-avatar v-if=\"isOAuthUser\" class=\"cur"
},
{
"path": "src/components/StatusCard/index.vue",
"chars": 15835,
"preview": "<template>\n <div class=\"status-card-container\" @dragenter=\"onDragFileOver\" ref=\"statusCardContainer\">\n <mu-card clas"
},
{
"path": "src/components/ThemeEditPanel.vue",
"chars": 8999,
"preview": "<template>\n <div class=\"theme-edit-panel-container\">\n <mu-dialog :open.sync=\"isDialogOpening\" overlay-color=\"rgba(0,"
},
{
"path": "src/components/VisibilitySelectPopOver.vue",
"chars": 1783,
"preview": "<template>\n <mu-popover cover :open.sync=\"shouldOpen\"\n :trigger=\"trigger\">\n <mu-list textline=\"two-line"
},
{
"path": "src/constant/i18n.ts",
"chars": 5039,
"preview": "export const I18nLocales = {\n EN: 'en',\n JA: 'ja',\n DE: 'de',\n ZH_CN: 'zh-cn',\n ZH_HK: 'zh-hk',\n ZH_TW: 'zh-tw'\n}\n"
},
{
"path": "src/constant/index.ts",
"chars": 1383,
"preview": "export { RoutersInfo } from './routers'\nexport { I18nTags, I18nLocales } from './i18n'\n\nconst AttachmentTypes = {\n IMAG"
},
{
"path": "src/constant/routers.ts",
"chars": 705,
"preview": "export const RoutersInfo = {\n empty: {\n path: '/',\n name: 'empty'\n },\n\n timelines: {\n path: '/timelines',\n "
},
{
"path": "src/directives.ts",
"chars": 3240,
"preview": "import Vue from 'vue'\nimport * as Masonry from 'masonry-layout'\nimport ResizeObserver from 'resize-observer-polyfill'\nim"
},
{
"path": "src/formatter.spec.ts",
"chars": 3321,
"preview": "import 'mocha'\nimport { expect } from 'chai'\nimport Formatter from './formatter'\n\nconst formatter = new Formatter()\n\ndes"
},
{
"path": "src/formatter.ts",
"chars": 2457,
"preview": "import { mastodonentities } from \"@/interface\"\n\nclass Formatter {\n\n private customEmojiRegex = /:\\w+:/g\n\n // todo fix "
},
{
"path": "src/i18n/compare",
"chars": 91,
"preview": "#!/bin/bash\n# Usage: ./compare en.ts ja.ts\ndiff <(cut -f 1 -d: \"$1\") <(cut -f 1 -d: \"$2\")\n\n"
},
{
"path": "src/i18n/de.ts",
"chars": 5575,
"preview": "import { I18nTags } from '@/constant'\n\nconst oauth = {\n [I18nTags.oauth.form_brand]: 'Cuckoo Plus',\n [I18nTags.oauth.l"
},
{
"path": "src/i18n/en.ts",
"chars": 5825,
"preview": "import { I18nTags } from '@/constant'\n\nconst oauth = {\n [I18nTags.oauth.form_brand]: 'Cuckoo Plus',\n [I18nTags.oauth.l"
},
{
"path": "src/i18n/index.ts",
"chars": 656,
"preview": "import Vue from 'vue'\nimport VueI18n from 'vue-i18n'\nimport { I18nLocales } from '@/constant'\nimport store from '@/store"
},
{
"path": "src/i18n/ja.ts",
"chars": 5223,
"preview": "import { I18nTags } from '@/constant'\n\nconst oauth = {\n [I18nTags.oauth.form_brand]: 'Cuckoo Plus',\n [I18nTags.oauth.l"
},
{
"path": "src/i18n/zh-cn.ts",
"chars": 4725,
"preview": "import { I18nTags } from '@/constant'\n\nconst oauth = {\n [I18nTags.oauth.form_brand]: '布谷鸟 Plus',\n [I18nTags.oauth.logi"
},
{
"path": "src/i18n/zh-hk.ts",
"chars": 4308,
"preview": "import { I18nTags } from '@/constant'\n\nconst oauth = {\n [I18nTags.oauth.form_brand]: '布穀鳥 Plus',\n [I18nTags.oauth.logi"
},
{
"path": "src/i18n/zh-tw.ts",
"chars": 4306,
"preview": "import { I18nTags } from '@/constant'\n\nconst oauth = {\n [I18nTags.oauth.form_brand]: '布穀鳥 Plus',\n [I18nTags.oauth.logi"
},
{
"path": "src/index.ts",
"chars": 1912,
"preview": "const Toast = require('muse-ui-toast').default\nconst Message = require('muse-ui-message').default\nconst Loading = requir"
},
{
"path": "src/interface/definition/vue-extend.d.ts",
"chars": 3741,
"preview": "import VueRouter, { Route } from \"vue-router\";\n\ninterface routerInfo { path: string, name: string }\n\ndeclare module \"vue"
},
{
"path": "src/interface/definition/vue-shims.d.ts",
"chars": 74,
"preview": "declare module \"*.vue\" {\n import Vue from \"vue\";\n export default Vue;\n}\n"
},
{
"path": "src/interface/entities.ts",
"chars": 8447,
"preview": "export namespace mastodonentities {\n\n export interface Application {\n\n }\n\n export interface Account {\n // The ID o"
},
{
"path": "src/interface/index.ts",
"chars": 103,
"preview": "export { cuckoostore } from '@/interface/store'\nexport { mastodonentities } from '@/interface/entities'"
},
{
"path": "src/interface/store.ts",
"chars": 2100,
"preview": "import { mastodonentities } from './entities'\n\nexport namespace cuckoostore {\n\n export interface stateInfo {\n OAuthI"
},
{
"path": "src/pages/Accounts/AccountHeader.vue",
"chars": 401,
"preview": "<template>\n <div class=\"account-header\">\n\n </div>\n</template>\n\n<script lang=\"ts\">\n import { Vue, Component } from 'vu"
},
{
"path": "src/pages/Accounts/index.vue",
"chars": 438,
"preview": "<template>\n <div class=\"account-container\">\n <account-header />\n </div>\n</template>\n\n<script lang=\"ts\">\n import { "
},
{
"path": "src/pages/OAuth.vue",
"chars": 4557,
"preview": "<template>\n <section class=\"oauth-container\">\n\n <div class=\"form-container\">\n <p class=\"oauth-form-brand\">{{$t("
},
{
"path": "src/pages/Settings.vue",
"chars": 15296,
"preview": "<template>\n <div class=\"setting-page-container\">\n <mu-card v-loading=\"isLoading\">\n <mu-card-actions class=\"sett"
},
{
"path": "src/pages/Statuses.vue",
"chars": 1367,
"preview": "<template>\n <div class=\"statuses-page-container\" v-loading=\"!status\">\n <status-card class=\"status-card-container\" v-"
},
{
"path": "src/pages/Timelines/NewStatusNoticeButton.vue",
"chars": 2759,
"preview": "<template>\n <mu-button v-if=\"!appStatus.settings.realTimeLoadStatusMode\" v-show=\"currentTimeLineStreamPool.length\"\n "
},
{
"path": "src/pages/Timelines/PostStatusStampCard.vue",
"chars": 1721,
"preview": "<template>\n <mu-card @click=\"onStampCardClick\" class=\"post-status-stamp-card\">\n <div class=\"left-area\">\n <mu-av"
},
{
"path": "src/pages/Timelines/index.vue",
"chars": 15007,
"preview": "<template>\n <div class=\"timelines-container\" ref=\"timelinesContainer\" v-loading=\"isInitLoading\">\n\n <template v-for=\""
},
{
"path": "src/router/index.ts",
"chars": 8247,
"preview": "import { TimeLineTypes } from \"../constant\";\n\nconst Loading = require('muse-ui-loading').default\nimport Vue from 'vue'\ni"
},
{
"path": "src/store/actions/accounts.ts",
"chars": 579,
"preview": "import * as Api from '@/api'\nimport { mastodonentities } from \"@/interface\"\n\nconst accounts = {\n async followAccountByI"
},
{
"path": "src/store/actions/appstatus.ts",
"chars": 1250,
"preview": "import { getTargetStatusesList } from '@/util'\nimport * as Api from '@/api'\nimport { mastodonentities } from \"@/interfac"
},
{
"path": "src/store/actions/index.ts",
"chars": 1094,
"preview": "import * as Api from '@/api'\nimport statuses from './statuses'\nimport timelines from './timelines'\nimport notifications "
},
{
"path": "src/store/actions/notifications.ts",
"chars": 1358,
"preview": "import * as api from '@/api'\nimport { NotificationTypes } from '@/constant'\nimport { mastodonentities } from \"@/interfac"
},
{
"path": "src/store/actions/relationships.ts",
"chars": 610,
"preview": "import * as Api from '@/api'\nimport { mastodonentities } from \"@/interface\"\n\nconst relationships = {\n async updateRelat"
},
{
"path": "src/store/actions/statuses.ts",
"chars": 4067,
"preview": "import * as api from '@/api'\nimport { TimeLineTypes } from '@/constant'\n\ninterface postStatusFormData {\n // The text of"
},
{
"path": "src/store/actions/timelines.ts",
"chars": 3182,
"preview": "import * as api from '@/api'\nimport { mastodonentities } from '@/interface'\nimport { isBaseTimeLine } from '@/util'\nimpo"
},
{
"path": "src/store/getters/index.ts",
"chars": 1821,
"preview": "import { cuckoostore, mastodonentities } from '@/interface'\nimport { isBaseTimeLine } from '@/util'\nimport { UiWidthChec"
},
{
"path": "src/store/index.ts",
"chars": 2896,
"preview": "import Vue from 'vue'\nimport Vuex from 'vuex'\nimport mutations from './mutations'\nimport actions from './actions'\nimport"
},
{
"path": "src/store/mutations/appstatus.ts",
"chars": 4714,
"preview": "import Vue from 'vue'\nimport { getTargetStatusesList } from '@/util'\nimport { ThemeNames } from '@/constant'\nimport { cu"
},
{
"path": "src/store/mutations/index.ts",
"chars": 4978,
"preview": "import Vue from 'vue'\nimport timelinesMutations from './timelines'\nimport notificationsMutations from './notifications'\n"
},
{
"path": "src/store/mutations/notifications.ts",
"chars": 1136,
"preview": "import Vue from 'vue'\nimport { cuckoostore, mastodonentities } from '@/interface'\nimport { formatAccountDisplayName, for"
},
{
"path": "src/store/mutations/timelines.ts",
"chars": 2316,
"preview": "import Vue from 'vue'\nimport { cuckoostore } from '@/interface'\nimport { TimeLineTypes } from '@/constant'\nimport { isBa"
},
{
"path": "src/themes/basecolor.ts",
"chars": 448,
"preview": "export default {\n '@primaryColor': '#2196f3',\n '@secondaryColor': '#ff4081',\n '@successColor': '#4caf50',\n '@warning"
},
{
"path": "src/themes/index.ts",
"chars": 5313,
"preview": "// @ts-ignore\nimport cuckooHubTheme from './presets/cuckoohub'\nimport greenLightTheme from './presets/greenlight'\nimport"
},
{
"path": "src/themes/presets/cuckoohub.ts",
"chars": 343,
"preview": "import darkTheme from './dark'\n\nconst colorSet = Object.assign({}, darkTheme.colorSet, {\n '@primaryColor': '#FF9900',\n "
},
{
"path": "src/themes/presets/dark.ts",
"chars": 369,
"preview": "const colorSet = {\n '@primaryColor': '#1976d2',\n '@secondaryColor': '#ff4081',\n '@trackColor': '#444b5d',\n\n '@textCo"
},
{
"path": "src/themes/presets/googleplus.ts",
"chars": 362,
"preview": "const colorSet = {\n '@primaryColor': '#db4437',\n '@secondaryColor': '#2b90d9',\n '@trackColor': '#bdbdbd',\n\n '@textCo"
},
{
"path": "src/themes/presets/greenlight.ts",
"chars": 234,
"preview": "import googlePlusTheme from './googleplus'\n\nconst colorSet = Object.assign({}, googlePlusTheme.colorSet, {\n '@primaryCo"
},
{
"path": "src/themes/stylepattern.ts",
"chars": 5594,
"preview": "const themeColorLessText = `\n.mu-primary-color {\n background-color: @primaryColor;\n}\n\n.mu-secondary-color {\n backgroun"
},
{
"path": "src/util.ts",
"chars": 9152,
"preview": "import store from '@/store'\nimport { TimeLineTypes, RoutersInfo, I18nTags, VisibilityTypes } from '@/constant'\nimport { "
},
{
"path": "tsconfig.json",
"chars": 435,
"preview": "{\n \"compilerOptions\": {\n \"baseUrl\": \".\",\n \"outDir\": \"./public/dist/\",\n \"sourceMap\": true,\n \"module\": \"commo"
},
{
"path": "webpack.config.js",
"chars": 3074,
"preview": "const webpack = require('webpack')\nconst path = require('path')\nconst yargs = require('yargs')\nconst fs = require('fs')\n"
}
]
// ... and 1 more files (download for full content)
About this extraction
This page contains the full source code of the NanaMorse/Cuckoo.Plus GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 95 files (330.8 KB), approximately 88.9k tokens, and a symbol index with 223 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.