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
================================================
Cuckoo+
================================================
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
================================================
================================================
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
}
}
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) {
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 {
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 }> {
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
}
async function getNotifications(queryParams: getNotificationsQueryParams): Promise<{ data: Array }> {
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 {
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
// 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 }> {
return Vue.http.get(patchApiUri(`/api/v1/statuses/${id}/reblogged_by`)) as any
}
async function getFavouritedAccountsById (id: string): Promise<{ data: Array }> {
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('', '').replace('', '')
}
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 }> {
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
================================================
================================================
FILE: src/components/Drawer/Search.vue
================================================
{{$t($i18nTags.drawer.search_result_people_label)}}
{{$t($i18nTags.drawer.search_result_hashtag_label)}}
================================================
FILE: src/components/Drawer/index.vue
================================================
{{$t(info.title)}}
# {{hashName}}
{{$t($i18nTags.drawer.settings)}}
================================================
FILE: src/components/Header.vue
================================================
================================================
FILE: src/components/Input.vue
================================================
================================================
FILE: src/components/Notifications/Card.vue
================================================
================================================
FILE: src/components/Notifications/index.vue
================================================
================================================
FILE: src/components/PostStatusDialog.vue
================================================
{{$t($i18nTags.statusCard.submit_post)}}
================================================
FILE: src/components/StatusCard/CardHeader.vue
================================================
================================================
FILE: src/components/StatusCard/FullActionBar.vue
================================================
================================================
FILE: src/components/StatusCard/FullReplyListItem.vue
================================================
{{ $t(shouldShowContentWhileSpoilerExists ? $i18nTags.statusCard.hide_content : $i18nTags.statusCard.show_content) }}
{{$t($i18nTags.statusCard.mute_status)}}
{{$t($i18nTags.statusCard.mute_user)}}
{{$t($i18nTags.statusCard.delete_status)}}
================================================
FILE: src/components/StatusCard/LinkPreviewPanel.vue
================================================
{{cardInfo.title}}
{{cardInfo.description}}
{{cardInfo.url}}
================================================
FILE: src/components/StatusCard/MediaPanel.vue
================================================
================================================
FILE: src/components/StatusCard/PlaceHolderMediaItem.vue
================================================
================================================
FILE: src/components/StatusCard/SimpleActionBar.vue
================================================
{{$t($i18nTags.statusCard.reply_to_main_status)}}
+1
{{operateCheckTargetStatus.favourites_count}}
{{operateCheckTargetStatus.reblogs_count}}
================================================
FILE: src/components/StatusCard/index.vue
================================================
{{ $t(shouldShowContentWhileSpoilerExists ? $i18nTags.statusCard.hide_content : $i18nTags.statusCard.show_content) }}
@{{currentReplyToStatus.account.username}}
================================================
FILE: src/components/ThemeEditPanel.vue
================================================
{{colorInfo.label}}
{{colorInfo.value}}
Cancel
Save
Simple
Advanced
================================================
FILE: src/components/VisibilitySelectPopOver.vue
================================================
{{$t(visibilityType)}}
{{$t(getVisibilityDescInfo(visibilityType).descTag)}}
================================================
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)
items: Array
}
}
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("aaaaaa")
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('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("aaa-aaa")
expect(formatter.format("-a-aa- aaa")).to.equal("a-aa aaa")
expect(formatter.format("-aaa- a-aa")).to.equal("aaa a-aa")
expect(formatter.format("aaa -a-aa-")).to.equal("aaa a-aa")
expect(formatter.format("a-aa -aaa-")).to.equal("a-aa aaa")
expect(formatter.format("aa -a-a- aa")).to.equal("aa a-a aa")
expect(formatter.format("a-a -aa- aa")).to.equal("a-a aa aa")
})
it('format string with a pair of correct dashes and a correct dash', () => {
expect(formatter.format("-a -aa- aaa")).to.equal("a -aa aaa")
expect(formatter.format("-aaa- a -aa")).to.equal("aaa a -aa")
expect(formatter.format("aaa -a -aa-")).to.equal("aaa a -aa")
expect(formatter.format("a- aa -aaa-")).to.equal("a- aa aaa")
expect(formatter.format("aa -a- a- aa")).to.equal("aa a- a aa")
expect(formatter.format("aa -a -a- aa")).to.equal("aa a -a aa")
expect(formatter.format("a- a -aa- aa")).to.equal("a- a aa aa")
})
// todo fix this
it('format string with two pairs of correct dashes', () => {
// expect(formatter.format("a -a- aa -a- a")).to.equal("a a aa a a")
// expect(formatter.format("-aa- aa -a- a")).to.equal("aa aa a a")
// expect(formatter.format("a -a- aa -aa-")).to.equal("a a aa aa")
// expect(formatter.format("-aa- aa -aa-")).to.equal("aa aa aa")
})
it('format string with two enclosing pairs of correct dashes', () => {
expect(formatter.format("a -a -aa- a- a")).to.equal("a a -aa- a 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 = []) {
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 `
`
})
}
public updateCustomEmojiMap (customEmojis: Array = []) {
customEmojis.forEach(emoji => {
this.customEmojiMap[emoji.shortcode] = emoji
})
}
public format (text: string, customEmojis: Array = []): 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}@{atName}',
[I18nTags.statusCard.sensitive_media_alert]: 'Ausgeblendeter Inhalt
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.common.drag_and_drop_to_upload]: 'Drag & Drop to upload',
[I18nTags.common.write_your_warning_here]: 'Write your warning here'
}
const statusCard = {
[I18nTags.statusCard.post_new_status_placeholder]: 'What is on your mind?',
[I18nTags.statusCard.reply_to_main_status]: 'Add a comment...',
[I18nTags.statusCard.reply_to_replier]: 'REPLY',
[I18nTags.statusCard.cancel_post]: 'CANCEL',
[I18nTags.statusCard.submit_post]: 'POST',
[I18nTags.statusCard.show_content]: 'SHOW MORE',
[I18nTags.statusCard.hide_content]: 'SHOW LESS',
[I18nTags.statusCard.mute_status]: 'Mute Post',
[I18nTags.statusCard.mute_user]: 'Mute User',
[I18nTags.statusCard.delete_status]: 'Delete',
[I18nTags.statusCard.delete_status_confirm]: 'Are you sure you want to delete the post?',
[I18nTags.statusCard.do_delete_status_btn]: 'DELETE',
[I18nTags.statusCard.cancel_delete_status_btn]: 'CANCEL',
[I18nTags.statusCard.mute_status_confirm]: 'Are you sure you want to mute the post?',
[I18nTags.statusCard.do_mute_status_btn]: 'MUTE',
[I18nTags.statusCard.cancel_mute_status_btn]: 'CANCEL',
[I18nTags.statusCard.mute_user_confirm]: 'Are you sure you want to mute this user?',
[I18nTags.statusCard.do_mute_user_btn]: 'MUTE',
[I18nTags.statusCard.cancel_mute_user_btn]: 'CANCEL',
[I18nTags.statusCard.originally_shared_by]: 'Originally shared by {displayName}@{atName}',
[I18nTags.statusCard.sensitive_media_alert]: 'Hide content
Click to view',
[I18nTags.statusCard.change_visibility]: 'Change Visibility',
[I18nTags.statusCard.add_photos]: 'Add Photos'
}
const drawer = {
[I18nTags.drawer.home]: 'Home',
[I18nTags.drawer.public]: 'Public',
[I18nTags.drawer.tag]: 'Tag',
[I18nTags.drawer.local]: 'Local',
[I18nTags.drawer.profile]: 'Profile',
[I18nTags.drawer.settings]: 'Settings',
[I18nTags.drawer.logout]: 'Logout',
[I18nTags.drawer.do_logout_message_confirm]: 'Are you sure you want to Logout?',
[I18nTags.drawer.do_logout_message_yes]: 'Yes',
[I18nTags.drawer.do_logout_message_no]: 'No',
[I18nTags.drawer.toHostInstance]: 'Open Current Instance Site',
[I18nTags.drawer.search_input_placeholder]: 'Search',
[I18nTags.drawer.search_result_people_label]: 'People',
[I18nTags.drawer.search_result_hashtag_label]: 'HashTag'
}
const settings = {
[I18nTags.settings.general_label]: 'General',
[I18nTags.settings.choose_theme]: 'Choose Theme:',
[I18nTags.settings.export_theme_color_set]: 'export',
[I18nTags.settings.import_theme_color_set]: 'import',
[I18nTags.settings.edit_theme_color_set]: 'edit',
[I18nTags.settings.delete_theme_color_set]: 'delete',
[I18nTags.settings.choose_language]: 'Choose Language:',
[I18nTags.settings.use_multi_line_mode]: 'Use multi-column layout mode:',
[I18nTags.settings.maximum_number_of_columns_in_multi_line_mode]: 'Maximum number of columns in multi-column layout mode:',
[I18nTags.settings.show_sensitive_media_files]: 'Always show media marked as sensitive:',
[I18nTags.settings.auto_expand_spoiler_text]: 'Always auto expand spoiler text:',
[I18nTags.settings.auto_load_new_status]: 'Always auto load new post:',
[I18nTags.settings.post_privacy]: 'Post privacy:',
[I18nTags.settings.post_media_as_sensitive]: 'Always mark media as sensitive:',
[I18nTags.settings.only_mention_target_user]: 'Only mention target user',
[I18nTags.settings.stream_label]: 'Stream',
[I18nTags.settings.media_label]: 'Media',
[I18nTags.settings.personality_label]: 'Personality',
[I18nTags.settings.publishing_label]: 'Publishing',
[I18nTags.settings.web_label]: 'Web',
[I18nTags.settings.changes_successfully_saved]: 'Changes successfully saved!'
}
const timeLines = {
[I18nTags.timeLines.no_load_more_status_notice]: 'You have seen all posts.',
[I18nTags.timeLines.new_message_notice]: '{count} new post | {count} new posts',
[I18nTags.timeLines.whats_new_with_you]: 'What\'s new with you?'
}
const postStatusDialog = {
[I18nTags.postStatusDialog.do_discard_message_confirm]: 'Discard this post?',
[I18nTags.postStatusDialog.do_keep_message]: 'KEEP',
[I18nTags.postStatusDialog.do_discard_message]: 'DISCARD',
[I18nTags.postStatusDialog.text_character_limit_exceed]: 'Text character limit of 500 exceeded'
}
const notifications = {
[I18nTags.notifications.someone_followed_you]: 'followed you',
[I18nTags.notifications.mentioned_you]: 'mentioned you',
[I18nTags.notifications.favourited_your_status]:'favourited your status',
[I18nTags.notifications.boosted_your_status]: 'boosted your status'
}
export default {
...oauth,
...common,
...statusCard,
...timeLines,
...drawer,
...settings,
...postStatusDialog,
...notifications
}
================================================
FILE: src/i18n/index.ts
================================================
import Vue from 'vue'
import VueI18n from 'vue-i18n'
import { I18nLocales } from '@/constant'
import store from '@/store'
import EN from './en'
import DE from './de'
import JA from './ja'
import ZH_CN from './zh-cn'
import ZH_HK from './zh-hk'
import ZH_TW from './zh-tw'
Vue.use(VueI18n)
const currentLocale = store.state.appStatus.settings.locale
const i18nMessages = {
[I18nLocales.EN]: EN,
[I18nLocales.DE]: DE,
[I18nLocales.JA]: JA,
[I18nLocales.ZH_CN]: ZH_CN,
[I18nLocales.ZH_HK]: ZH_HK,
[I18nLocales.ZH_TW]: ZH_TW
}
export default new VueI18n({
locale: currentLocale,
messages: i18nMessages,
fallbackLocale: I18nLocales.EN
})
================================================
FILE: src/i18n/ja.ts
================================================
import { I18nTags } from '@/constant'
const oauth = {
[I18nTags.oauth.form_brand]: 'Cuckoo Plus',
[I18nTags.oauth.login_hint]: '連携ログイン',
[I18nTags.oauth.server_input_label]: 'マストドンのURL',
[I18nTags.oauth.please_input_server_url]: 'マストドンのURLを入力してください',
[I18nTags.oauth.please_input_correct_server_url]: 'マストドンのURLを確認してください',
[I18nTags.oauth.register_app_error_message]: '何かがおかしいです! マストドンのURLを確認してください',
[I18nTags.oauth.confirm_input]: '確認'
}
const common = {
[I18nTags.common.status_visibility_public]: '公開',
[I18nTags.common.status_visibility_unlisted]: '未収載',
[I18nTags.common.status_visibility_private]: 'フォロワー限定',
[I18nTags.common.status_visibility_direct]: 'ダイレクト',
[I18nTags.common.status_visibility_public_desc]: '公開TLに投稿する',
[I18nTags.common.status_visibility_unlisted_desc]: '公開TLで表示しない',
[I18nTags.common.status_visibility_private_desc]: 'フォロワーだけに公開',
[I18nTags.common.status_visibility_direct_desc]: 'メンションしたユーザーだけに公開',
[I18nTags.common.drag_and_drop_to_upload]: 'ドラッグ&ドロップでアップロード',
[I18nTags.common.write_your_warning_here]: 'ここに警告を書いてください'
}
const statusCard = {
[I18nTags.statusCard.post_new_status_placeholder]: '今なにしてる?',
[I18nTags.statusCard.reply_to_main_status]: 'コメントを追加してください...',
[I18nTags.statusCard.reply_to_replier]: '返信',
[I18nTags.statusCard.cancel_post]: 'キャンセル',
[I18nTags.statusCard.submit_post]: '送信',
[I18nTags.statusCard.show_content]: '開く',
[I18nTags.statusCard.hide_content]: '閉じる',
[I18nTags.statusCard.mute_status]: '投稿をミュート',
[I18nTags.statusCard.mute_user]: 'ユーザーをミュート',
[I18nTags.statusCard.delete_status]: '削除',
[I18nTags.statusCard.delete_status_confirm]: 'この投稿を削除してもよろしいですか?',
[I18nTags.statusCard.do_delete_status_btn]: '削除',
[I18nTags.statusCard.cancel_delete_status_btn]: 'キャンセル',
[I18nTags.statusCard.mute_status_confirm]: 'この投稿をミュートしてもよろしいですか?',
[I18nTags.statusCard.do_mute_status_btn]: 'ミュート',
[I18nTags.statusCard.cancel_mute_status_btn]: 'キャンセル',
[I18nTags.statusCard.mute_user_confirm]: 'このユーザーをミュートしてもよろしいですか?',
[I18nTags.statusCard.do_mute_user_btn]: 'ミュート',
[I18nTags.statusCard.cancel_mute_user_btn]: 'キャンセル',
[I18nTags.statusCard.originally_shared_by]: '{displayName}@{atName} さんから',
[I18nTags.statusCard.sensitive_media_alert]: '隠されたメディア
クリックで開きます',
[I18nTags.statusCard.change_visibility]: '表示/非表示を切り替え',
[I18nTags.statusCard.add_photos]: '画像を追加'
}
const drawer = {
[I18nTags.drawer.home]: 'ホーム',
[I18nTags.drawer.public]: 'パブリック',
[I18nTags.drawer.tag]: 'タグ',
[I18nTags.drawer.local]: 'ローカル',
[I18nTags.drawer.profile]: 'プロフィール',
[I18nTags.drawer.settings]: '設定',
[I18nTags.drawer.logout]: 'ログアウト',
[I18nTags.drawer.do_logout_message_confirm]: 'ログアウトしてもよろしいですか?' ,
[I18nTags.drawer.do_logout_message_yes]: 'はい',
[I18nTags.drawer.do_logout_message_no]: 'いいえ',
[I18nTags.drawer.toHostInstance]: '現在のインスタンスを開く',
[I18nTags.drawer.search_input_placeholder]: '検索',
[I18nTags.drawer.search_result_people_label]: 'ユーザー',
[I18nTags.drawer.search_result_hashtag_label]: 'ハッシュタグ'
}
const settings = {
[I18nTags.settings.general_label]: '一般',
[I18nTags.settings.choose_theme]: 'テーマ:',
[I18nTags.settings.export_theme_color_set]: 'エクスポート',
[I18nTags.settings.import_theme_color_set]: 'インポート',
[I18nTags.settings.edit_theme_color_set]: '編集',
[I18nTags.settings.delete_theme_color_set]: '削除',
[I18nTags.settings.choose_language]: '言語:',
[I18nTags.settings.use_multi_line_mode]: 'マルチカラムレイアウトを使う:',
[I18nTags.settings.maximum_number_of_columns_in_multi_line_mode]: 'マルチカラムレイアウトの最大カラム数:',
[I18nTags.settings.show_sensitive_media_files]: 'メディアを常に閲覧注意としてマークする:',
[I18nTags.settings.auto_expand_spoiler_text]: '警告内容を自動的に表示する:',
[I18nTags.settings.auto_load_new_status]: '新しい投稿を常に自動的に読み込む:',
[I18nTags.settings.post_privacy]: '投稿の公開範囲:',
[I18nTags.settings.post_media_as_sensitive]: '自分が投稿するメディアを常に閲覧注意 (NSFW) に設定する:',
[I18nTags.settings.only_mention_target_user]: 'ターゲットユーザーのみをメンションする:',
[I18nTags.settings.stream_label]: 'ストリーム',
[I18nTags.settings.media_label]: 'メディア',
[I18nTags.settings.personality_label]: 'パーソナリティ',
[I18nTags.settings.publishing_label]: '投稿',
[I18nTags.settings.web_label]: 'ウェブ',
[I18nTags.settings.changes_successfully_saved]: '正常に変更されました!'
}
const timeLines = {
[I18nTags.timeLines.no_load_more_status_notice]: 'すべての投稿を見ました。',
[I18nTags.timeLines.new_message_notice]: '新しい投稿 {count} | 新しい投稿 {count}',
[I18nTags.timeLines.whats_new_with_you]: '最近の出来事を共有してみましょう'
}
const postStatusDialog = {
[I18nTags.postStatusDialog.do_discard_message_confirm]: 'この投稿を破棄しますか?',
[I18nTags.postStatusDialog.do_keep_message]: '保持',
[I18nTags.postStatusDialog.do_discard_message]: '破棄',
[I18nTags.postStatusDialog.text_character_limit_exceed]: '文字数が500を超えています。'
}
const notifications = {
[I18nTags.notifications.someone_followed_you]: 'さんがあなたをフォローしています',
[I18nTags.notifications.mentioned_you]: '返信',
[I18nTags.notifications.favourited_your_status]:'お気に入り',
[I18nTags.notifications.boosted_your_status]: 'ブースト'
}
export default {
...oauth,
...common,
...statusCard,
...timeLines,
...drawer,
...settings,
...postStatusDialog,
...notifications
}
================================================
FILE: src/i18n/zh-cn.ts
================================================
import { I18nTags } from '@/constant'
const oauth = {
[I18nTags.oauth.form_brand]: '布谷鸟 Plus',
[I18nTags.oauth.login_hint]: '授权登录',
[I18nTags.oauth.server_input_label]: 'Mastodon 链接',
[I18nTags.oauth.please_input_server_url]: '请输入 Mastodon 链接',
[I18nTags.oauth.please_input_correct_server_url]: '请输入正确的 Mastodon 链接',
[I18nTags.oauth.register_app_error_message]: '出错啦!检查一下目标链接是否正确吧',
[I18nTags.oauth.confirm_input]: '确认'
}
const common = {
[I18nTags.common.status_visibility_public]: '公开',
[I18nTags.common.status_visibility_unlisted]: '不公开',
[I18nTags.common.status_visibility_private]: '仅关注者',
[I18nTags.common.status_visibility_direct]: '私信',
[I18nTags.common.status_visibility_public_desc]: '所有人可见,并会出现在公共时间轴上',
[I18nTags.common.status_visibility_unlisted_desc]: '所有人可见,但不会出现在公共时间轴上',
[I18nTags.common.status_visibility_private_desc]: '只有关注你的用户能看到',
[I18nTags.common.status_visibility_direct_desc]: '只有被提及的用户能看到',
[I18nTags.common.drag_and_drop_to_upload]: '将文件拖放至此处开始上传',
[I18nTags.common.write_your_warning_here]: '折叠部分的警告消息'
}
const statusCard = {
[I18nTags.statusCard.post_new_status_placeholder]: '你最近有什么新鲜事要分享吗?',
[I18nTags.statusCard.reply_to_main_status]: '发表评论…',
[I18nTags.statusCard.reply_to_replier]: '回复',
[I18nTags.statusCard.cancel_post]: '取消',
[I18nTags.statusCard.submit_post]: '发布',
[I18nTags.statusCard.show_content]: '显示内容',
[I18nTags.statusCard.hide_content]: '隐藏内容',
[I18nTags.statusCard.mute_status]: '忽略嘟文',
[I18nTags.statusCard.mute_status_confirm]: '要忽略这条嘟文吗?',
[I18nTags.statusCard.do_mute_status_btn]: '忽略',
[I18nTags.statusCard.cancel_mute_user_btn]: '取消',
[I18nTags.statusCard.mute_user]: '忽略用户',
[I18nTags.statusCard.mute_user_confirm]: '要忽略该用户吗?',
[I18nTags.statusCard.do_mute_user_btn]: '忽略',
[I18nTags.statusCard.cancel_mute_user_btn]: '取消',
[I18nTags.statusCard.delete_status]: '删除',
[I18nTags.statusCard.delete_status_confirm]: '要删除这条嘟文吗?',
[I18nTags.statusCard.do_delete_status_btn]: '删除',
[I18nTags.statusCard.cancel_delete_status_btn]: '取消',
[I18nTags.statusCard.originally_shared_by]: '此信息最初是由{displayName}@{atName}分享的',
[I18nTags.statusCard.sensitive_media_alert]: '隐藏媒体内容
点击显示'
}
const drawer = {
[I18nTags.drawer.home]: '主页',
[I18nTags.drawer.public]: '公共',
[I18nTags.drawer.local]: '本站时间轴',
[I18nTags.drawer.tag]: '标签',
[I18nTags.drawer.profile]: '个人资料',
[I18nTags.drawer.settings]: '设置',
[I18nTags.drawer.logout]: '注销',
[I18nTags.drawer.do_logout_message_confirm]: '你确定要注销Cuckoo吗?' ,
[I18nTags.drawer.do_logout_message_yes]: '是的',
[I18nTags.drawer.do_logout_message_no]: '我只是手滑',
[I18nTags.drawer.toHostInstance]: '打开当前实例站点',
[I18nTags.drawer.search_input_placeholder]: '搜索',
[I18nTags.drawer.search_result_people_label]: '用户',
[I18nTags.drawer.search_result_hashtag_label]: '话题标签'
}
const settings = {
[I18nTags.settings.general_label]: '常规',
[I18nTags.settings.choose_theme]: '选择主题:',
[I18nTags.settings.choose_language]: '选择语言:',
[I18nTags.settings.use_multi_line_mode]: '使用多列布局模式:',
[I18nTags.settings.maximum_number_of_columns_in_multi_line_mode]: '多列布局模式下的最大列数:',
[I18nTags.settings.show_sensitive_media_files]: '总是显示被标记为敏感的媒体文件:',
[I18nTags.settings.auto_expand_spoiler_text]: '总是显示被警告折叠的文本内容:',
[I18nTags.settings.auto_load_new_status]: '总是自动加载新的嘟文:',
[I18nTags.settings.post_privacy]: '嘟文默认可见范围:',
[I18nTags.settings.post_media_as_sensitive]: '总是将我发送的媒体文件标记为敏感内容:',
[I18nTags.settings.only_mention_target_user]: '回复时仅提及目标使用者',
[I18nTags.settings.stream_label]: '信息流',
[I18nTags.settings.media_label]: '媒体内容',
[I18nTags.settings.personality_label]: '个性化',
[I18nTags.settings.publishing_label]: '发布',
[I18nTags.settings.web_label]: '站内',
[I18nTags.settings.changes_successfully_saved]: '更改保存成功!'
}
const timeLines = {
[I18nTags.timeLines.no_load_more_status_notice]: '没有更多啦!',
[I18nTags.timeLines.new_message_notice]: '{count}条新信息',
[I18nTags.timeLines.whats_new_with_you]: '你最近有什么新鲜事要分享吗?'
}
const postStatusDialog = {
[I18nTags.postStatusDialog.do_discard_message_confirm]: '要舍弃这条信息吗?',
[I18nTags.postStatusDialog.do_keep_message]: '保留',
[I18nTags.postStatusDialog.do_discard_message]: '舍弃',
[I18nTags.postStatusDialog.text_character_limit_exceed]: '内容超过500个字符的限制了'
}
const notifications = {
[I18nTags.notifications.someone_followed_you]: '关注了你',
[I18nTags.notifications.mentioned_you]: '提及了你',
[I18nTags.notifications.favourited_your_status]:'喜欢了你的嘟文',
[I18nTags.notifications.boosted_your_status]: '转发了你的嘟文'
}
export default {
...oauth,
...common,
...statusCard,
...timeLines,
...drawer,
...settings,
...postStatusDialog,
...notifications
}
================================================
FILE: src/i18n/zh-hk.ts
================================================
import { I18nTags } from '@/constant'
const oauth = {
[I18nTags.oauth.form_brand]: '布穀鳥 Plus',
[I18nTags.oauth.login_hint]: '授權登錄',
[I18nTags.oauth.server_input_label]: 'Mastodon 連結',
[I18nTags.oauth.please_input_server_url]: '請輸入 Mastodon 連結',
[I18nTags.oauth.please_input_correct_server_url]: '請輸入準確的 Mastodon 連結',
[I18nTags.oauth.register_app_error_message]: '請檢查目標連結是否準確',
[I18nTags.oauth.confirm_input]: '確認'
}
const common = {
[I18nTags.common.status_visibility_public]: '公共',
[I18nTags.common.status_visibility_unlisted]: '公開',
[I18nTags.common.status_visibility_private]: '關注者',
[I18nTags.common.status_visibility_direct]: '私人訊息',
[I18nTags.common.status_visibility_public_desc]: '在公共時間軸顯示',
[I18nTags.common.status_visibility_unlisted_desc]: '公開,但不在公共時間軸顯示',
[I18nTags.common.status_visibility_private_desc]: '只有關注你用戶能看到',
[I18nTags.common.status_visibility_direct_desc]: '只有提及的用戶能看到',
[I18nTags.common.drag_and_drop_to_upload]: '將檔案拖放至此上載',
[I18nTags.common.write_your_warning_here]: '敏感警告訊息'
}
const statusCard = {
[I18nTags.statusCard.post_new_status_placeholder]: '你有什麼新動態?',
[I18nTags.statusCard.reply_to_main_status]: '新增留言…',
[I18nTags.statusCard.reply_to_replier]: '回覆',
[I18nTags.statusCard.cancel_post]: '取消',
[I18nTags.statusCard.submit_post]: '發佈',
[I18nTags.statusCard.show_content]: '顯示內容',
[I18nTags.statusCard.hide_content]: '隱藏內容',
[I18nTags.statusCard.mute_status]: '忽略',
[I18nTags.statusCard.delete_status]: '刪除',
[I18nTags.statusCard.delete_status_confirm]: '你確定要刪除這則訊息嗎?',
[I18nTags.statusCard.do_delete_status_btn]: '刪除',
[I18nTags.statusCard.cancel_delete_status_btn]: '取消',
[I18nTags.statusCard.originally_shared_by]: '最初由{displayName}@{atName}分享',
[I18nTags.statusCard.sensitive_media_alert]: '隐藏內容
點擊顯示'
}
const drawer = {
[I18nTags.drawer.home]: '主頁',
[I18nTags.drawer.public]: '跨站時間軸',
[I18nTags.drawer.tag]: '標籤',
[I18nTags.drawer.local]: '本站時間軸',
[I18nTags.drawer.profile]: '個人檔案',
[I18nTags.drawer.settings]: '設定',
[I18nTags.drawer.logout]: '登出',
[I18nTags.drawer.do_logout_message_confirm]: '確定登出Cuckoo?' ,
[I18nTags.drawer.do_logout_message_yes]: '是',
[I18nTags.drawer.do_logout_message_no]: '否',
[I18nTags.drawer.toHostInstance]: '前往當前實例站點',
[I18nTags.drawer.search_input_placeholder]: '搜索',
[I18nTags.drawer.search_result_people_label]: '用戶',
[I18nTags.drawer.search_result_hashtag_label]: '話題標籤'
}
const settings = {
[I18nTags.settings.general_label]: '一般',
[I18nTags.settings.choose_theme]: '選擇主題:',
[I18nTags.settings.choose_language]: '選擇語言:',
[I18nTags.settings.use_multi_line_mode]: '使用多列佈局模式:',
[I18nTags.settings.maximum_number_of_columns_in_multi_line_mode]: '多列佈局模式下的最大列數:',
[I18nTags.settings.show_sensitive_media_files]: '總是顯示被標記為敏感的媒體文件:',
[I18nTags.settings.auto_expand_spoiler_text]: '總是顯示被警告折疊的文本內容:',
[I18nTags.settings.auto_load_new_status]: '總是自動加載最新嘟文:',
[I18nTags.settings.post_privacy]: '文章預設為:',
[I18nTags.settings.post_media_as_sensitive]: '預設我的內容為敏感內容:',
[I18nTags.settings.only_mention_target_user]: '回覆時僅提及目標使用者',
[I18nTags.settings.stream_label]: '訊息串',
[I18nTags.settings.media_label]: '媒體內容',
[I18nTags.settings.personality_label]: '個人化',
[I18nTags.settings.publishing_label]: '發佈',
[I18nTags.settings.web_label]: '站内',
[I18nTags.settings.changes_successfully_saved]: '已成功儲存修改'
}
const timeLines = {
[I18nTags.timeLines.no_load_more_status_notice]: '你已看完了所有訊息',
[I18nTags.timeLines.new_message_notice]: '{count}條新訊息',
[I18nTags.timeLines.whats_new_with_you]: '你有什麼新動態?'
}
const postStatusDialog = {
[I18nTags.postStatusDialog.do_discard_message_confirm]: '確定要捨棄這則訊息嗎?',
[I18nTags.postStatusDialog.do_keep_message]: '保留',
[I18nTags.postStatusDialog.do_discard_message]: '捨棄',
[I18nTags.postStatusDialog.text_character_limit_exceed]: '內容超出500個字符的限制了'
}
const notifications = {
[I18nTags.notifications.someone_followed_you]: '關注了你',
[I18nTags.notifications.mentioned_you]: '提及了你',
[I18nTags.notifications.favourited_your_status]:'收藏了你的文章',
[I18nTags.notifications.boosted_your_status]: '轉推你的文章'
}
export default {
...oauth,
...common,
...statusCard,
...timeLines,
...drawer,
...settings,
...postStatusDialog,
...notifications
}
================================================
FILE: src/i18n/zh-tw.ts
================================================
import { I18nTags } from '@/constant'
const oauth = {
[I18nTags.oauth.form_brand]: '布穀鳥 Plus',
[I18nTags.oauth.login_hint]: '授權登入',
[I18nTags.oauth.server_input_label]: 'Mastodon 連結',
[I18nTags.oauth.please_input_server_url]: '請輸入 Mastodon 連結',
[I18nTags.oauth.please_input_correct_server_url]: '請輸入準確的 Mastodon 連結',
[I18nTags.oauth.register_app_error_message]: '請檢查目標連結是否準確',
[I18nTags.oauth.confirm_input]: '確認'
}
const common = {
[I18nTags.common.status_visibility_public]: '公開貼',
[I18nTags.common.status_visibility_unlisted]: '不列出來',
[I18nTags.common.status_visibility_private]: '關注貼',
[I18nTags.common.status_visibility_direct]: '直接貼',
[I18nTags.common.status_visibility_public_desc]: '貼到公開時間軸',
[I18nTags.common.status_visibility_unlisted_desc]: '不要貼到公開時間軸',
[I18nTags.common.status_visibility_private_desc]: '只貼給關注者',
[I18nTags.common.status_visibility_direct_desc]: '只貼給提到的使用者',
[I18nTags.common.drag_and_drop_to_upload]: '將檔案拖放至此上載',
[I18nTags.common.write_your_warning_here]: '敏感警告訊息'
}
const statusCard = {
[I18nTags.statusCard.post_new_status_placeholder]: '最近有什麼新鮮事?',
[I18nTags.statusCard.reply_to_main_status]: '發表留言...',
[I18nTags.statusCard.reply_to_replier]: '回覆',
[I18nTags.statusCard.cancel_post]: '取消',
[I18nTags.statusCard.submit_post]: '發佈',
[I18nTags.statusCard.show_content]: '顯示內容',
[I18nTags.statusCard.hide_content]: '隱藏內容',
[I18nTags.statusCard.mute_status]: '忽略',
[I18nTags.statusCard.delete_status]: '刪除',
[I18nTags.statusCard.delete_status_confirm]: '確定要刪除這則訊息嗎?',
[I18nTags.statusCard.do_delete_status_btn]: '删除',
[I18nTags.statusCard.cancel_delete_status_btn]: '取消',
[I18nTags.statusCard.originally_shared_by]: '{displayName}@{atName}最先分享這則訊息',
[I18nTags.statusCard.sensitive_media_alert]: '隐藏內容
點來看'
}
const drawer = {
[I18nTags.drawer.home]: '首頁',
[I18nTags.drawer.public]: '聯盟時間軸',
[I18nTags.drawer.local]: '本地時間軸',
[I18nTags.drawer.tag]: '主題標籤',
[I18nTags.drawer.profile]: '個人資料',
[I18nTags.drawer.settings]: '設定',
[I18nTags.drawer.logout]: '登出',
[I18nTags.drawer.do_logout_message_confirm]: '確定登出Cuckoo?' ,
[I18nTags.drawer.do_logout_message_yes]: '是',
[I18nTags.drawer.do_logout_message_no]: '否',
[I18nTags.drawer.toHostInstance]: '前往當前實例站點',
[I18nTags.drawer.search_input_placeholder]: '搜索',
[I18nTags.drawer.search_result_people_label]: '用戶',
[I18nTags.drawer.search_result_hashtag_label]: '話題標籤'
}
const settings = {
[I18nTags.settings.general_label]: '一般',
[I18nTags.settings.choose_theme]: '選擇主題:',
[I18nTags.settings.choose_language]: '選擇語言:',
[I18nTags.settings.use_multi_line_mode]: '使用多列佈局模式:',
[I18nTags.settings.maximum_number_of_columns_in_multi_line_mode]: '多列佈局模式下的最大列數:',
[I18nTags.settings.show_sensitive_media_files]: '總是顯示被標記為敏感的媒體文件:',
[I18nTags.settings.auto_expand_spoiler_text]: '總是顯示被警告折疊的文本內容:',
[I18nTags.settings.auto_load_new_status]: '總是自動加載最新嘟文:',
[I18nTags.settings.post_privacy]: '文章預設為:',
[I18nTags.settings.post_media_as_sensitive]: '預設我的內容為敏感內容:',
[I18nTags.settings.only_mention_target_user]: '回覆時僅提及目標使用者',
[I18nTags.settings.stream_label]: '訊息串',
[I18nTags.settings.media_label]: '媒體內容',
[I18nTags.settings.personality_label]: '個人化',
[I18nTags.settings.publishing_label]: '發佈',
[I18nTags.settings.web_label]: '站内',
[I18nTags.settings.changes_successfully_saved]: '已成功儲存修改'
}
const timeLines = {
[I18nTags.timeLines.no_load_more_status_notice]: '你已看完了所有訊息',
[I18nTags.timeLines.new_message_notice]: '{count}條新訊息',
[I18nTags.timeLines.whats_new_with_you]: '最近有什麼新鮮事?'
}
const postStatusDialog = {
[I18nTags.postStatusDialog.do_discard_message_confirm]: '確定要捨棄這則訊息嗎?',
[I18nTags.postStatusDialog.do_keep_message]: '保留',
[I18nTags.postStatusDialog.do_discard_message]: '捨棄',
[I18nTags.postStatusDialog.text_character_limit_exceed]: '內容超出500個字符的限制了'
}
const notifications = {
[I18nTags.notifications.someone_followed_you]: '關注了你',
[I18nTags.notifications.mentioned_you]: '提及了你',
[I18nTags.notifications.favourited_your_status]:'收藏了你的文章',
[I18nTags.notifications.boosted_your_status]: '轉推你的文章'
}
export default {
...oauth,
...common,
...statusCard,
...timeLines,
...drawer,
...settings,
...postStatusDialog,
...notifications
}
================================================
FILE: src/index.ts
================================================
const Toast = require('muse-ui-toast').default
const Message = require('muse-ui-message').default
const Loading = require('muse-ui-loading').default
const NProgress = require('muse-ui-progress').default
import Vue from 'vue'
import MuseUI from 'muse-ui'
import 'muse-ui-loading/dist/muse-ui-loading.css'
import 'muse-ui-progress/dist/muse-ui-progress.css'
import VueResource from 'vue-resource'
import i18n from './i18n'
import store from './store'
import router from './router'
import App from './App.vue'
import * as moment from 'moment'
import { I18nTags, RoutersInfo, I18nLocales } from '@/constant'
import ThemeManager from '@/themes'
import './directives'
Vue.use({
install (Vue) {
Vue.prototype.$i18nTags = I18nTags;
Vue.prototype.$routersInfo = RoutersInfo;
}
})
Vue.use(MuseUI)
Vue.use(VueResource)
Vue.use(Toast, {
position: 'bottom-start'
})
Vue.use(Message)
Vue.use(NProgress, {
color: 'primary',
zIndex: 9999999999,
})
Vue.use(Loading, {
overlayColor: 'hsla(0,0%,100%,.9)',
size: 48,
color: 'primary',
})
const currentLocale = store.state.appStatus.settings.locale
moment.locale(currentLocale)
const httpInterceptor: any = (request) => {
request.headers.set('Authorization', `Bearer ${store.state.OAuthInfo.accessToken}`);
}
Vue.http.interceptors.push(httpInterceptor)
// @ts-ignore
if (window.Notification) {
Notification.requestPermission()
}
ThemeManager.setTheme(store.state.appStatus.settings.theme)
if ('serviceWorker' in navigator && (process.env.NODE_ENV !== 'develop')) {
navigator.serviceWorker.register('/sw.js')
}
if (process.env.NODE_ENV === 'develop') {
navigator.serviceWorker && navigator.serviceWorker.getRegistrations()
.then(registrations => {
for(let registration of registrations) {
registration.unregister()
}
})
}
new Vue({
el: '#app',
store,
router,
i18n,
render(h) {
return h(App)
}
});
================================================
FILE: src/interface/definition/vue-extend.d.ts
================================================
import VueRouter, { Route } from "vue-router";
interface routerInfo { path: string, name: string }
declare module "vue/types/vue" {
interface Vue {
$router: VueRouter;
$route: Route;
$routersInfo: {
empty: routerInfo
home: routerInfo
oauth: routerInfo
settings: routerInfo
statuses: routerInfo
timelines: routerInfo
defaulttimelines: routerInfo
tagtimelines: routerInfo
listtimelines: routerInfo
accounts: routerInfo
}
$i18nTags: {
statusCard: {
post_new_status_placeholder: string
reply_to_replier: string
reply_to_main_status: string
cancel_post: string
submit_post: string
show_content: string
hide_content: string
mute_status: string
delete_status: string
delete_status_confirm: string
do_delete_status_btn: string
cancel_delete_status_btn: string
originally_shared_by: string
sensitive_media_alert: string
change_visibility: string
add_photos: string
},
common: {
status_visibility_public: string
status_visibility_private: string
status_visibility_unlisted: string
status_visibility_direct: string
status_visibility_public_desc: string,
status_visibility_private_desc: string,
status_visibility_unlisted_desc: string,
status_visibility_direct_desc: string,
drag_and_drop_to_upload: string
write_your_warning_here: string
},
timeLines: {
no_load_more_status_notice: string
new_message_notice: string
whats_new_with_you: string
},
header: {
},
drawer: {
home: string
public: string
tag: string
profile: string
settings: string
toHostInstance: string
search_input_placeholder: string
search_result_people_label: string
search_result_hashtag_label: string
do_logout_message_confirm: string
do_logout_message_yes: string
do_logout_message_no: string
},
settings: {
general_label: string
choose_theme: string
export_theme_color_set: string
import_theme_color_set: string
edit_theme_color_set: string
delete_theme_color_set: string
choose_language: string
use_multi_line_mode: string
show_sensitive_media_files: string
auto_load_new_status: string
post_privacy: string
post_media_as_sensitive: string
only_mention_target_user: string
maximum_number_of_columns_in_multi_line_mode: string
auto_expand_spoiler_text: string
stream_label: string
media_label: string
publishing_label: string
personality_label: string
web_label: string
changes_successfully_saved: string
},
home: {
},
oauth: {
form_brand: string
login_hint: string
server_input_label: string
please_input_server_url: string
please_input_correct_server_url: string
register_app_error_message: string
confirm_input: string
},
postStatusDialog: {
do_discard_message_confirm: string
do_keep_message: string
do_discard_message: string
text_character_limit_exceed: string
},
notifications: {
someone_followed_you: string
mentioned_you: string
boosted_your_status: string
favourited_your_status: string
}
}
$toast: {
error: (msg: string) => void
}
$confirm: (message: string, title: string, options) => Promise<{ result: boolean }>
}
}
================================================
FILE: src/interface/definition/vue-shims.d.ts
================================================
declare module "*.vue" {
import Vue from "vue";
export default Vue;
}
================================================
FILE: src/interface/entities.ts
================================================
export namespace mastodonentities {
export interface Application {
}
export interface Account {
// The ID of the account
id: string
// The username of the account
username: string
// Equals username for local users, includes @domain for remote ones
acct: string
// The account's display name
display_name: string
// Boolean for when the account cannot be followed without waiting for approval first
locked: string
// The time the account was created
created_at: string
// The number of followers for the account
followers_count: string
// The number of accounts the given account is following
following_count: string
// The number of statuses the account has made
statuses_count: string
// Biography of user
note: string
// URL of the user's profile page (can be remote)
url: string
// URL to the avatar image
avatar: string
// URL to the avatar static image (gif)
avatar_static: string
// URL to the header image
header: string
// URL to the header static image (gif)
header_static: string
// Array of Emoji in account username and note
emojis: Array
// If the owner decided to switch accounts, new account is in this attribute
moved?: any
// Array of profile metadata field, each element has 'name' and 'value'
fields?: Array
// Boolean to indicate that the account performs automated actions
bot?: boolean
}
export interface AuthenticatedAccount extends Account {
source: {
// Selected preference: Default privacy of new toots
privacy: string
// Selected preference: Mark media as sensitive by default?
sensitive: boolean
// Plain-text version of the account's note
note: string
// Array of profile metadata, each element has 'name' and 'value'
fields: Array
}
}
export interface Status {
// The ID of the status
id: string
// A Fediverse-unique resource ID
uri: string
// URL to the status page (can be remote)
url?: string
// The Account which posted the status
account: Account
// null or the ID of the status it replies to
// in_reply_to_id and in_reply_to_account_id are null if the status that is replied to is unknown
in_reply_to_id?: string
// null or the ID of the account it replies to
in_reply_to_account_id?: string
// null or the reblogged Status
reblog?: Status
// Body of the status; this will contain HTML (remote HTML already sanitized)
content: string
// The time the status was created
created_at: string
// An array of Emoji
emojis: Array
// The number of replies for the status
replies_count: number
// The number of reblogs for the status
reblogs_count: number
// The number of favourites for the status
favourites_count: number
// Whether the authenticated user has reblogged the status
reblogged?: boolean
// Whether the authenticated user has favourited the status
favourited?: boolean
// Whether the authenticated user has muted the conversation this status from
muted?: boolean
// Whether media attachments should be hidden by default
// NOTE: When spoiler_text is present, sensitive is true
sensitive?: boolean
// If not empty, warning text that should be displayed before the actual content
spoiler_text: string
// One of: public, unlisted, private, direct
visibility: string
// An array of Attachments
media_attachments: Array
// An array of Mentions
mentions: Array
// An array of Tags
tags: Array
// Application from which the status was posted
application?: Application
// The detected language for the status, if detected
language?: string
// Whether this is the pinned status for the account that posted it
pinned?: boolean
// for pawoo, pixiv_cards info
pixiv_cards?: Array<{ image_url: string, url: string }>
}
export interface Context {
ancestors: Array
descendants: Array
}
export interface Emoji {
}
export interface Attachment {
// ID of the attachment
id: string
// One of: "image", "video", "gifv", "unknown"
type: "image" | "video" | "gifv" | "unknown"
// URL of the locally hosted version of the image
url: string
// For remote images, the remote URL of the original image
remote_url?: string
// URL of the preview image
preview_url: string
// Shorter URL for the image, for insertion into text (only present on local images)
text_url?: string
/**
* May contain small and original (referring to the preview and the original file).
* Images may contain width, height, size, aspect,
* while videos (including GIFV) may contain width, height,
* frame_rate, duration and bitrate. There may be another top-level object,
* focus with the coordinates x and y.
* These coordinates can be used for smart thumbnail cropping
**/
meta?: ImageMeta | GifvMeta
// A description of the image for the visually impaired (maximum 420 characters), or null if none provided
description?: string
}
interface ImageSizeMetaItem {
aspect: number
width: number
height: number
size: string
}
export interface ImageMeta {
focus: { x: number, y: number }
original: ImageSizeMetaItem
small: ImageSizeMetaItem
}
export interface GifvMeta extends ImageSizeMetaItem {
duration: number
fps: number
length: string
original: {
bitrate: number
duration: number
frame_rate: string
height: number
width: number
}
small: ImageSizeMetaItem
}
export interface Mention {
// URL of user's profile (can be remote)
url: string
// The username of the account
username: string
// Equals username for local users, includes @domain for remote ones
acct: string
// Account ID
id: string
}
export interface Tag {
}
export interface Notification {
// The notification ID
id: string
// One of: "mention", "reblog", "favourite", "follow"
type: NotificationType
// The time the notification was created
created_at: string
// The Account sending the notification to the user
account: Account
// The Status associated with the notification, if applicable
status?: Status
}
export type NotificationType = "mention" | "reblog" | "favourite" | "follow"
export interface SearchResults {
accounts: Array
statuses: Array
hashtags: Array
}
export interface Emoji {
// The shortcode of the emoji
shortcode: string
// URL to the emoji static image
static_url: string
// URL to the emoji image
url: string
// that indicates if the emoji is visible in picker
visible_in_picker: boolean
}
export interface Relationship {
// Target account id
id: string
// Whether the user is currently following the account
following: boolean
// Whether the user is currently being followed by the account
followed_by: boolean
// Whether the user is currently blocking the account
blocking: boolean
// Whether the user is currently muting the account
muting: boolean
// Whether the user is also muting notifications no
muting_notifications: boolean
// Whether the user has requested to follow the account
requested: boolean
// Whether the user is currently blocking the accounts's domain
domain_blocking: boolean
// Whether the user's reblogs will show up in the home timeline
showing_reblogs: boolean
// Whether the user is currently endorsing the account
endorsed: boolean
}
export interface Card {
// The url associated with the card
url: string
// The title of the card
title: string
// The card description
description: string
// The image associated with the card, if any yes
image: string
// "link", "photo", "video", or "rich"
type: string
// OEmbed data
author_name: string
// OEmbed data
author_url: string
// OEmbed data yes
provider_name: string
// OEmbed data yes
provider_url: string
// OEmbed data yes
html: string
// OEmbed data yes
width: number
// OEmbed data
height: number
}
}
================================================
FILE: src/interface/index.ts
================================================
export { cuckoostore } from '@/interface/store'
export { mastodonentities } from '@/interface/entities'
================================================
FILE: src/interface/store.ts
================================================
import { mastodonentities } from './entities'
export namespace cuckoostore {
export interface stateInfo {
OAuthInfo: OAuthInfo
mastodonServerUri: string
currentUserAccount: mastodonentities.Account
timelines: {
home: Array
public: Array
direct: Array
local: Array
tag: {
[index: string]: Array
}
list: {
[index: string]: Array
}
}
contextMap: {
[statusId: string]: {
ancestors: Array
descendants: Array
}
}
cardMap: {
[statusId: string]: mastodonentities.Card
}
statusMap: {
[statusId: string]: mastodonentities.Status
}
notifications: Array
relationships: {
[id: string]: mastodonentities.Relationship
}
customEmojis: Array
appStatus: {
documentWidth: number
isDrawerOpened: boolean
isNotificationsPanelOpened: boolean
unreadNotificationCount: number
isEditingThemeMode: boolean
shouldShowThemeEditPanel: boolean
streamStatusesPool: {
home: Array
public: Array
direct: Array
local: Array
tag: {
[index: string]: Array
}
list: {
[index: string]: Array
}
}
settings: {
multiLineMode: boolean
maximumNumberOfColumnsInMultiLineMode: number
showSensitiveContentMode: boolean
postMediaAsSensitiveMode: boolean
realTimeLoadStatusMode: boolean
autoExpandSpoilerTextMode: boolean
onlyMentionTargetUserMode: boolean
theme: string,
tags: Array
locale: string,
postPrivacy: string
muteMap: {
statusList: Array
userList: Array
}
}
}
}
export interface OAuthInfo {
clientId: string
clientSecret: string
accessToken: string
code: string
}
}
================================================
FILE: src/pages/Accounts/AccountHeader.vue
================================================
================================================
FILE: src/pages/Accounts/index.vue
================================================
================================================
FILE: src/pages/OAuth.vue
================================================
================================================
FILE: src/pages/Settings.vue
================================================
{{$t($i18nTags.settings.general_label)}}
{{$t($i18nTags.settings.choose_theme)}}
{{$t($i18nTags.settings.choose_theme)}}
Cancel
Export
{{$t($i18nTags.settings.choose_theme)}}
Cancel
Delete
{{$t($i18nTags.settings.choose_language)}}
{{$t($i18nTags.settings.stream_label)}}
{{$t($i18nTags.settings.auto_load_new_status)}}
{{$t($i18nTags.settings.use_multi_line_mode)}}
{{$t($i18nTags.settings.maximum_number_of_columns_in_multi_line_mode)}}
{{$t($i18nTags.settings.publishing_label)}}
{{$t($i18nTags.settings.post_privacy)}}
{{$t($i18nTags.settings.post_media_as_sensitive)}}
{{$t($i18nTags.settings.only_mention_target_user)}}
{{$t($i18nTags.settings.web_label)}}
{{$t($i18nTags.settings.show_sensitive_media_files)}}
{{$t($i18nTags.settings.auto_expand_spoiler_text)}}
================================================
FILE: src/pages/Statuses.vue
================================================
================================================
FILE: src/pages/Timelines/NewStatusNoticeButton.vue
================================================
{{$tc($i18nTags.timeLines.new_message_notice, currentTimeLineStreamPool.length, { count: currentTimeLineStreamPool.length })}}
================================================
FILE: src/pages/Timelines/PostStatusStampCard.vue
================================================
{{$t($i18nTags.timeLines.whats_new_with_you)}}
================================================
FILE: src/pages/Timelines/index.vue
================================================
{{$t($i18nTags.timeLines.no_load_more_status_notice)}}
{{snackBarMessage}}
Close
================================================
FILE: src/router/index.ts
================================================
import { TimeLineTypes } from "../constant";
const Loading = require('muse-ui-loading').default
import Vue from 'vue'
import Router, { Route } from 'vue-router'
import store from '../store'
import { RoutersInfo } from '@/constant'
import * as Api from '@/api'
import { isBaseTimeLine } from '@/util'
import TimeLinesPage from '@/pages/Timelines'
import OAuthPage from '@/pages/OAuth'
import StatusesPage from '@/pages/Statuses'
import Settings from '@/pages/Settings'
import AccountsPage from '@/pages/Accounts'
Vue.use(Router)
const homePath = '/timelines/home'
const localPath = '/timelines/local'
const publicPath = '/timelines/public'
const router = new Router({
routes: [
{
path: RoutersInfo.empty.path,
redirect: homePath
},
{
path: RoutersInfo.timelines.path,
redirect: homePath
},
{
path: RoutersInfo.accounts.path,
name: RoutersInfo.accounts.name,
component: AccountsPage
},
{
path: RoutersInfo.statuses.path,
name: RoutersInfo.statuses.name,
component: StatusesPage
},
{
path: RoutersInfo.timelines.path,
name: RoutersInfo.timelines.name,
component: TimeLinesPage,
meta: {
needOAuth: true
},
children: [
{
path: RoutersInfo.defaulttimelines.path,
name: RoutersInfo.defaulttimelines.name,
meta: {
keepAlive: true,
needOAuth: true
}
},
{
path: RoutersInfo.tagtimelines.path,
name: RoutersInfo.tagtimelines.name,
meta: {
keepAlive: true,
needOAuth: true
}
},
{
path: RoutersInfo.listtimelines.path,
name: RoutersInfo.listtimelines.name,
meta: {
keepAlive: true,
needOAuth: true
}
}
]
},
{
path: RoutersInfo.oauth.path,
name: RoutersInfo.oauth.name,
component: OAuthPage,
beforeEnter (to, from, next) {
if (!checkShouldRegisterApplication(to, from)) {
next(RoutersInfo.empty.path)
}
next()
},
meta: {
hideHeader: true,
hideDrawer: true
}
},
{
path: RoutersInfo.settings.path,
name: RoutersInfo.settings.name,
component: Settings,
meta: {
needOAuth: true
}
}
]
} as any);
function checkShouldRegisterApplication (to, from): boolean {
// should have clientId/clientSecret/code
const { clientId, clientSecret } = store.state.OAuthInfo
let code = store.state.OAuthInfo.code
if (from.path === '/' && !code) {
if (location.search.substring(0,6) == "?code=") {
code = (new RegExp("[\\?&]code=([^]*)")).exec(location.search)
code = code == null ? "": decodeURIComponent(code[1]);
// todo maybe shouldn't put this here?
store.commit('updateOAuthCode', code)
}
}
return !(clientId && clientSecret && store.state.mastodonServerUri && code)
}
const statusInitManager = new class {
private hasInitFetchNotifications: boolean = false
private hasInitStreamConnection: boolean = false
private hasInitLocalStreamConnection: boolean = false
private hasInitPublicStreamConnection: boolean = false
private hasUpdateOAuthAccessToken: boolean = false
private hasUpdateCurrentUserAccount: boolean = false
private hasUpdateCustomEmojis: boolean = false
private loadingInstance = null
private loadingProcessList = []
private startLoading (process: string) {
this.loadingProcessList.push(process)
this.loadingInstance = Loading() || this.loadingInstance
}
private endLoading () {
if (this.loadingProcessList.every(process => this[process])) {
try {
this.loadingInstance && this.loadingInstance.close()
} catch (e) {
}
}
}
public initFetchNotifications () {
if (!store.state.notifications.length && !this.hasInitFetchNotifications) {
store.dispatch('updateNotifications')
this.hasInitFetchNotifications = true
}
}
public initStreamConnection () {
if (!this.hasInitStreamConnection) {
Api.streaming.openUserConnection()
this.hasInitStreamConnection = true
}
}
public initLocalStreamConnection () {
if (!this.hasInitLocalStreamConnection) {
Api.streaming.openLocalConnection()
this.hasInitLocalStreamConnection = true
}
}
public destroyLocalStreamConnection () {
if (this.hasInitLocalStreamConnection) {
Api.streaming.closeConnection(TimeLineTypes.LOCAL)
this.hasInitLocalStreamConnection = false
}
}
public initPublicStreamConnection () {
if (!this.hasInitPublicStreamConnection) {
Api.streaming.openPublicConnection()
this.hasInitPublicStreamConnection = true
}
}
public destroyPublicStreamConnection () {
if (this.hasInitPublicStreamConnection) {
Api.streaming.closeConnection(TimeLineTypes.PUBLIC)
this.hasInitPublicStreamConnection = false
}
}
public async updateCurrentUserAccount () {
if (!this.hasUpdateCurrentUserAccount) {
if (!store.state.currentUserAccount) {
this.startLoading('hasUpdateCurrentUserAccount')
await store.dispatch('updateCurrentUserAccount')
} else {
store.dispatch('updateCurrentUserAccount')
}
this.hasUpdateCurrentUserAccount = true
this.endLoading()
}
}
public async updateOAuthAccessToken () {
if (!store.state.OAuthInfo.accessToken && !this.hasUpdateOAuthAccessToken) {
this.startLoading('updateOAuthAccessToken')
const result = await Api.oauth.fetchOAuthToken()
store.commit('updateOAuthAccessToken', result.data.access_token)
this.hasUpdateOAuthAccessToken = true
this.endLoading()
}
}
public async updateCustomEmojis () {
if (!this.hasUpdateCustomEmojis) {
if (!store.state.customEmojis.length) {
this.startLoading('hasUpdateCustomEmojis')
await store.dispatch('updateCustomEmojis')
} else {
store.dispatch('updateCustomEmojis')
}
this.hasUpdateCustomEmojis = true
this.endLoading()
}
}
}
let hasUpdateCurrentUserAccount = false
const beforeEachHooks = {
async beforeEachRoute (to, from, next) {
await statusInitManager.updateCustomEmojis()
next()
},
// children routes can't use in-router guide...
beforeDefaultTimeLines (to: Route, from, next) {
if (to.name === RoutersInfo.defaulttimelines.name) {
if (!isBaseTimeLine(to.params.timeLineType)) {
return next(homePath)
}
}
next()
},
async beforeNeedOAuthRoutes (to, from, next) {
if (to.meta.needOAuth) {
// check if need to register
if (checkShouldRegisterApplication(to, from)) {
store.commit('clearAllOAuthInfo')
return next(RoutersInfo.oauth.path)
}
// check if need to get token
// check if should to be blocked by user fetch
try {
await statusInitManager.updateOAuthAccessToken()
await statusInitManager.updateCurrentUserAccount()
} catch (e) {
store.commit('clearAllOAuthInfo')
return next(RoutersInfo.oauth.path)
}
// should fetch notifications
statusInitManager.initFetchNotifications()
}
next()
},
beforeHomeTimeLine (to, from, next) {
if (to.path === homePath) {
statusInitManager.initStreamConnection()
}
next()
},
beforeLocalTimeLine (to, from, next) {
if (to.path === localPath) {
statusInitManager.initLocalStreamConnection()
}
next()
},
afterLocalTimeLine (to, from, next) {
if (from.path === localPath) {
statusInitManager.destroyLocalStreamConnection()
}
next()
},
beforePublicTimeLine (to, from, next) {
if (to.path === publicPath) {
statusInitManager.initPublicStreamConnection()
}
next()
},
afterPublicTimeLine (to, from, next) {
if (from.path === publicPath) {
statusInitManager.destroyPublicStreamConnection()
}
next()
}
}
Object.keys(beforeEachHooks).forEach(key => {
router.beforeEach(beforeEachHooks[key])
})
export default router
================================================
FILE: src/store/actions/accounts.ts
================================================
import * as Api from '@/api'
import { mastodonentities } from "@/interface"
const accounts = {
async followAccountById ({ commit }, id: string) {
try {
const result = await Api.accounts.followAccountById(id)
commit('updateRelationships', { [result.data.id]: result.data })
} catch (e) {
}
},
async unFollowAccountById ({ commit }, id: string) {
try {
const result = await Api.accounts.unFollowAccountById(id)
commit('updateRelationships', { [result.data.id]: result.data })
} catch (e) {
}
}
}
export default accounts
================================================
FILE: src/store/actions/appstatus.ts
================================================
import { getTargetStatusesList } from '@/util'
import * as Api from '@/api'
import { mastodonentities } from "@/interface"
const appStatus = {
loadStreamStatusesPool ({ commit, state }, { timeLineType, hashName }) {
const targetStreamPool = getTargetStatusesList(state.appStatus.streamStatusesPool, timeLineType, hashName)
commit('unShiftTimeLineStatuses', {
newStatusIdList: targetStreamPool.filter(id => state.statusMap[id]),
timeLineType, hashName
})
commit('clearStreamStatusesPool', { timeLineType, hashName })
},
async updatePostPrivacy ({ commit }, newPrivacy: string) {
try {
await Api.accounts.updateUserAccountInfo({ source: { privacy: newPrivacy } })
commit('updatePostPrivacy', newPrivacy)
} catch (e) {
// todo log error
commit('updatePostPrivacy', newPrivacy)
}
},
async updatePostMediaAsSensitiveMode ({ commit }, newSensitiveMode: boolean) {
try {
await Api.accounts.updateUserAccountInfo({ source: { sensitive: newSensitiveMode } })
commit('updatePostMediaAsSensitiveMode', newSensitiveMode)
} catch (e) {
// todo log error
commit('updatePostMediaAsSensitiveMode', newSensitiveMode)
}
}
}
export default appStatus
================================================
FILE: src/store/actions/index.ts
================================================
import * as Api from '@/api'
import statuses from './statuses'
import timelines from './timelines'
import notifications from './notifications'
import appstatus from './appstatus'
import relationships from './relationships'
import accounts from './accounts'
import { mastodonentities } from "@/interface"
const actions = {
...timelines,
...statuses,
...notifications,
...appstatus,
...relationships,
...accounts,
async updateCurrentUserAccount ({ commit }) {
try {
const result = await Api.accounts.fetchCurrentUserAccountInfo()
const accountInfo: mastodonentities.AuthenticatedAccount = result.data
commit('updateCurrentUserAccount', accountInfo)
// sync settings
commit('updatePostPrivacy', accountInfo.source.privacy)
commit('updatePostMediaAsSensitiveMode', accountInfo.source.sensitive)
} catch (e) {
}
},
async updateCustomEmojis ({ commit }) {
try {
const result = await Api.instances.getCustomEmojis()
commit('updateCustomEmojis', result.data)
} catch (e) {
}
}
}
export default actions
================================================
FILE: src/store/actions/notifications.ts
================================================
import * as api from '@/api'
import { NotificationTypes } from '@/constant'
import { mastodonentities } from "@/interface"
const notifications = {
async updateNotifications ({ commit, state, dispatch }, { isLoadMore, isFetchMore } = {
isLoadMore: false,
isFetchMore: false
}) {
const notifications: Array = state.notifications
let maxId, sinceId
if (isLoadMore) {
maxId = notifications[notifications.length - 1].id
} else if (isFetchMore) {
sinceId = notifications[0] ? notifications[0].id : null
}
let mutationName = ''
if (!isLoadMore && !isFetchMore) mutationName = 'pushNotifications'
if (isLoadMore && !isFetchMore) mutationName = 'pushNotifications'
if (!isLoadMore && isFetchMore) mutationName = 'unShiftNotification'
try {
const result = await api.notifications.getNotifications({ max_id: maxId, since_id: sinceId })
commit(mutationName, result.data)
const followNotifications: Array = result.data.filter(notification => notification.type === NotificationTypes.FOLLOW)
if (followNotifications.length) {
dispatch('updateRelationships', { idList: followNotifications.map(notification => notification.account.id) })
}
} catch (e) {
}
}
}
export default notifications
================================================
FILE: src/store/actions/relationships.ts
================================================
import * as Api from '@/api'
import { mastodonentities } from "@/interface"
const relationships = {
async updateRelationships ({ commit }, { idList }: { idList: Array }) {
try {
const result = await Api.accounts.fetchRelationships(idList || [])
const relationshipList: Array = result.data
const relationshipMap = {}
relationshipList.forEach(relationship => {
relationshipMap[relationship.id] = relationship
})
commit('updateRelationships', relationshipMap)
} catch (e) {
}
}
}
export default relationships
================================================
FILE: src/store/actions/statuses.ts
================================================
import * as api from '@/api'
import { TimeLineTypes } from '@/constant'
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
// 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
}
const statuses = {
async fetchStatusById ({ commit, dispatch }, statusId: string) {
try {
const result = await api.statuses.getStatusById(statusId)
commit('updateStatusMap', { [statusId]: result.data })
dispatch('updateContextMap', statusId)
dispatch('updateCardMap', statusId)
} catch (e) {
throw new Error(e)
}
},
async updateFavouriteStatusById ({ commit }, { favourited, mainStatusId, targetStatusId }) {
try {
if (favourited) {
api.statuses.favouriteStatusById(targetStatusId)
} else {
api.statuses.unFavouriteStatusById(targetStatusId)
}
commit('updateFavouriteStatusById', { favourited, mainStatusId, targetStatusId })
} catch (e) {
throw new Error(e)
}
},
async updateReblogStatusById ({ commit }, { reblogged, mainStatusId, targetStatusId }) {
try {
if (reblogged) {
api.statuses.reblogStatusById(targetStatusId)
} else {
api.statuses.unReblogStatusById(targetStatusId)
}
commit('updateReblogStatusById', { reblogged, mainStatusId, targetStatusId })
} catch (e) {
throw new Error(e)
}
},
async updateContextMap ({ commit, dispatch }, statusId: string) {
if (!statusId) throw new Error('unknown status id!')
try {
const result = await api.statuses.getStatusContextById(statusId)
const ancestors = result.data.ancestors
const descendants = result.data.descendants
commit('updateContextMap', { [statusId]: {
ancestors: ancestors.map(status => status.id),
descendants: descendants.map(status => status.id) }
})
const newStatusMap = {}
ancestors.forEach(status => newStatusMap[status.id] = status)
descendants.forEach(status => newStatusMap[status.id] = status)
commit('updateStatusMap', newStatusMap)
} catch (e) {
}
},
async updateCardMap (store, statusId: string) {
const targetStatus = store.state.statusMap[statusId]
if (targetStatus.pixiv_cards && targetStatus.pixiv_cards.length > 0) return
if (store.state.cardMap[statusId]) return
try {
const result = await api.statuses.getStatusCardInfoById(statusId)
store.commit('updateCardMap', { [statusId]: result.data })
} catch (e) {
}
},
async postStatus ({ commit, dispatch }, { formData, mainStatusId }: {
formData: postStatusFormData
mainStatusId: string
}) {
try {
const result = await api.statuses.postStatus(formData)
// meaning this is a new root post
if (!formData.inReplyToId) {
// todo 默认只有home信息流,真的好吗?
commit('unShiftTimeLineStatuses', {
newStatusIdList: [result.data.id],
timeLineType: TimeLineTypes.HOME
})
} else {
// update the reply status's context
await dispatch('updateContextMap', mainStatusId)
}
// update status map
commit('updateStatusMap', { [result.data.id]: result.data })
dispatch('updateCardMap', result.data.id)
} catch (e) {
throw new Error(e)
}
},
async deleteStatus ({ commit }, { statusId }) {
// remove from time line
commit('deleteStatusFromTimeLine', statusId)
// remove from status map
commit('removeStatusFromStatusMapById', statusId)
try {
await api.statuses.deleteStatusById(statusId)
} catch (e) {
}
}
}
export default statuses
================================================
FILE: src/store/actions/timelines.ts
================================================
import * as api from '@/api'
import { mastodonentities } from '@/interface'
import { isBaseTimeLine } from '@/util'
import { TimeLineTypes } from '@/constant'
export default {
async updateTimeLineStatuses ({ commit, dispatch, state }, { timeLineType, hashName, isLoadMore, isFetchMore }: {
timeLineType: string
hashName?: string
isLoadMore?: boolean
isFetchMore?: boolean
}) {
if (!timeLineType) throw new Error('set time line type!')
let targetStatusIdList: Array
if (isBaseTimeLine(timeLineType)) {
targetStatusIdList = state.timelines[timeLineType]
} else {
targetStatusIdList = state.timelines[timeLineType][hashName] || []
}
let maxId, sinceId
if (isLoadMore) {
maxId = targetStatusIdList[targetStatusIdList.length - 1]
} else if (isFetchMore) {
sinceId = targetStatusIdList[0]
}
let mutationName = ''
if (!isLoadMore && !isFetchMore) mutationName = 'setTimeLineStatuses'
if (isLoadMore && !isFetchMore) mutationName = 'pushTimeLineStatuses'
if (!isLoadMore && isFetchMore) mutationName = 'unShiftTimeLineStatuses'
try {
const result = await api.timelines.getTimeLineStatuses({ timeLineType, hashName, maxId, sinceId })
const resultToFetchContext = result.data.filter((status: mastodonentities.Status) => {
// remove for some instance's replies_count has bug
return !status.in_reply_to_id
})
// update context map
// optimize only home time line's result should check context
if (timeLineType === TimeLineTypes.HOME) {
Promise.all(resultToFetchContext.map((status: mastodonentities.Status) => {
return api.statuses.getStatusContextById(status.id)
})).then(results => {
const newContextMap = {}
const newStatusMap = {}
results.forEach((contextResult, index) => {
const descendantIdList = contextResult.data.descendants.map(status => status.id)
// only record descendant here
if (descendantIdList.length) {
newContextMap[resultToFetchContext[index].id] = {
ancestors: contextResult.data.ancestors.map(status => status.id),
descendants: descendantIdList
}
}
contextResult.data.ancestors.forEach(status => newStatusMap[status.id] = status)
contextResult.data.descendants.forEach(status => newStatusMap[status.id] = status)
})
Object.keys(newContextMap).length && commit('updateContextMap', newContextMap)
// also update status map
Object.keys(newStatusMap).length && commit('updateStatusMap', newStatusMap)
})
}
// update status map
const newStatusMap = {}
result.data.forEach(status => newStatusMap[status.id] = status)
commit('updateStatusMap', newStatusMap)
Object.keys(newStatusMap).forEach(statusId => {
dispatch('updateCardMap', statusId)
})
commit(mutationName, { newStatusIdList: result.data.map(status => status.id), timeLineType, hashName })
return result
} catch (e) {
throw e
}
}
}
================================================
FILE: src/store/getters/index.ts
================================================
import { cuckoostore, mastodonentities } from '@/interface'
import { isBaseTimeLine } from '@/util'
import { UiWidthCheckConstants } from '@/constant'
const accounts = {
getAccountDisplayName () {
return (account: mastodonentities.Account) => account.display_name || account.username || account.acct
},
getAccountAtName () {
return (account: mastodonentities.Account) => account.username || account.acct
}
}
const timelines = {
getRootStatuses (state: cuckoostore.stateInfo) {
return (timeLineType: string, hashName?: string): Array => {
const targetStatusIdList = isBaseTimeLine(timeLineType) ? state.timelines[timeLineType] :
state.timelines[timeLineType][hashName]
// todo avoid this situation
if (!targetStatusIdList) return []
return targetStatusIdList
.map(statusId => state.statusMap[statusId]).filter(status => status)
.filter((status: mastodonentities.Status) => !status.in_reply_to_id)
.filter(status => {
const muteStatusList = state.appStatus.settings.muteMap.statusList
return muteStatusList.indexOf(status.id) === -1
}).filter(status => {
const muteUserList = state.appStatus.settings.muteMap.userList
return muteUserList.indexOf(status.account.id) === -1
})
}
}
}
const getters = {
...accounts,
...timelines,
isOAuthUser (state: cuckoostore.stateInfo) {
return state.OAuthInfo.accessToken
},
isMobileMode (state: cuckoostore.stateInfo) {
return state.appStatus.documentWidth < UiWidthCheckConstants.DRAWER_DOCKING_BOUNDARY
},
shouldDialogFullScreen (state: cuckoostore.stateInfo) {
return state.appStatus.documentWidth <= UiWidthCheckConstants.POST_STATUS_DIALOG_TOGGLE_WIDTH
}
}
export default getters
================================================
FILE: src/store/index.ts
================================================
import Vue from 'vue'
import Vuex from 'vuex'
import mutations from './mutations'
import actions from './actions'
import getters from './getters'
import { cuckoostore } from '@/interface'
import { UiWidthCheckConstants, ThemeNames, I18nLocales, VisibilityTypes } from '@/constant'
Vue.use(Vuex)
function getLocalSetting (tag, defaultValue) {
const stringData = localStorage.getItem(tag)
if (!stringData) return defaultValue
const parsedData = JSON.parse(stringData)
if (Object.keys(parsedData).length >= 500) return defaultValue
return parsedData
}
const state: cuckoostore.stateInfo = {
OAuthInfo: {
// todo encode
clientId: localStorage.getItem('clientId') || '',
clientSecret: localStorage.getItem('clientSecret') || '',
accessToken: localStorage.getItem('accessToken') || '',
code: localStorage.getItem('code') || ''
},
mastodonServerUri: localStorage.getItem('mastodonServerUri') || '',
currentUserAccount: getLocalSetting('currentUserAccount', null),
timelines: {
home: getLocalSetting('home', []),
public: [],
direct: [],
local: [],
tag: {},
list: {}
},
contextMap: getLocalSetting('contextMap', {}),
statusMap: getLocalSetting('statusMap', {}),
cardMap: getLocalSetting('cardMap', {}),
customEmojis: getLocalSetting('customEmojis', []),
notifications: [],
relationships: {},
appStatus: {
documentWidth: window.innerWidth,
isDrawerOpened: window.innerWidth > UiWidthCheckConstants.DRAWER_DOCKING_BOUNDARY,
isNotificationsPanelOpened: false,
unreadNotificationCount: 0,
isEditingThemeMode: false,
shouldShowThemeEditPanel: false,
streamStatusesPool: {
home: [],
public: [],
direct: [],
local: [],
tag: {},
list: {}
},
settings: {
multiLineMode: getLocalSetting('multiLineMode', true),
maximumNumberOfColumnsInMultiLineMode: getLocalSetting('maximumNumberOfColumnsInMultiLineMode', 3),
showSensitiveContentMode: getLocalSetting('showSensitiveContentMode', false),
realTimeLoadStatusMode: getLocalSetting('realTimeLoadStatusMode', false),
autoExpandSpoilerTextMode: getLocalSetting('autoExpandSpoilerTextMode', false),
postMediaAsSensitiveMode: getLocalSetting('postMediaAsSensitiveMode', false),
theme: localStorage.getItem('theme') || ThemeNames.GOOGLE_PLUS,
tags: getLocalSetting('tags', ['hello']),
locale: localStorage.getItem('locale') || I18nLocales.EN,
postPrivacy: localStorage.getItem('postPrivacy') || VisibilityTypes.PUBLIC,
onlyMentionTargetUserMode: getLocalSetting('onlyMentionTargetUserMode', false),
muteMap: {
statusList: getLocalSetting('statusMuteList', []),
userList: getLocalSetting('userMuteList', [])
},
},
}
}
export default new Vuex.Store({
state,
mutations,
actions,
getters
})
================================================
FILE: src/store/mutations/appstatus.ts
================================================
import Vue from 'vue'
import { getTargetStatusesList } from '@/util'
import { ThemeNames } from '@/constant'
import { cuckoostore } from '@/interface'
import ThemeManager from '@/themes'
export default {
updateDrawerOpenStatus (state: cuckoostore.stateInfo, isDrawerOpened: boolean) {
state.appStatus.isDrawerOpened = isDrawerOpened
},
updateNotificationsPanelStatus (state: cuckoostore.stateInfo, isNotificationsPanelOpened: boolean) {
state.appStatus.isNotificationsPanelOpened = isNotificationsPanelOpened
},
updateUnreadNotificationCount (state: cuckoostore.stateInfo, count: number) {
state.appStatus.unreadNotificationCount = count
},
updateDocumentWidth (state: cuckoostore.stateInfo) {
state.appStatus.documentWidth = window.innerWidth
},
updateTheme (state: cuckoostore.stateInfo, newThemeName: string) {
state.appStatus.settings.theme = newThemeName
localStorage.setItem('theme', newThemeName)
},
updateTags (state: cuckoostore.stateInfo, newTags: Array) {
Vue.set(state.appStatus.settings, 'tags', newTags)
localStorage.setItem('tags', JSON.stringify(newTags))
},
updateMultiLineMode (state: cuckoostore.stateInfo, newMode: boolean) {
state.appStatus.settings.multiLineMode = newMode
localStorage.setItem('multiLineMode', JSON.stringify(newMode))
},
updateShowSensitiveContentMode (state: cuckoostore.stateInfo, newMode: boolean) {
state.appStatus.settings.showSensitiveContentMode = newMode
localStorage.setItem('showSensitiveContentMode', JSON.stringify(newMode))
},
updateRealTimeLoadStatusMode (state: cuckoostore.stateInfo, newMode: boolean) {
state.appStatus.settings.realTimeLoadStatusMode = newMode
localStorage.setItem('realTimeLoadStatusMode', JSON.stringify(newMode))
},
updateLocale (state: cuckoostore.stateInfo, newLocale: string) {
state.appStatus.settings.locale = newLocale
localStorage.setItem('locale', newLocale)
},
updateMuteStatusList (state: cuckoostore.stateInfo, statusId: string) {
const statusList: Array = state.appStatus.settings.muteMap.statusList
if (statusList.indexOf(statusId) === -1) statusList.push(statusId)
localStorage.setItem('statusMuteList', JSON.stringify(statusList))
},
updateMuteUserList (state: cuckoostore.stateInfo, userId: string) {
const userList: Array = state.appStatus.settings.muteMap.userList
if (userList.indexOf(userId) === -1) userList.push(userId)
localStorage.setItem('userMuteList', JSON.stringify(userList))
},
unShiftStreamStatusesPool (state: cuckoostore.stateInfo, { newStatusIdList, timeLineType, hashName }) {
const targetStatusesPool = getTargetStatusesList(state.appStatus.streamStatusesPool, timeLineType, hashName)
newStatusIdList = newStatusIdList.filter(id => {
return targetStatusesPool.indexOf(id) === -1
})
targetStatusesPool.unshift(...newStatusIdList)
},
clearStreamStatusesPool (state: cuckoostore.stateInfo, { timeLineType, hashName }) {
const targetStatusesPool = getTargetStatusesList(state.appStatus.streamStatusesPool, timeLineType, hashName)
targetStatusesPool.splice(0, targetStatusesPool.length)
},
updatePostPrivacy (state: cuckoostore.stateInfo, newPostPrivacy: string) {
state.appStatus.settings.postPrivacy = newPostPrivacy
localStorage.setItem('postPrivacy', newPostPrivacy)
},
updatePostMediaAsSensitiveMode (state: cuckoostore.stateInfo, newMode: boolean) {
state.appStatus.settings.postMediaAsSensitiveMode = newMode
localStorage.setItem('postMediaAsSensitiveMode', JSON.stringify(newMode))
},
updateOnlyMentionTargetUserMode (state: cuckoostore.stateInfo, newMode: boolean) {
state.appStatus.settings.onlyMentionTargetUserMode = newMode
localStorage.setItem('onlyMentionTargetUserMode', JSON.stringify(newMode))
},
updateMaximumNumberOfColumnsInMultiLineMode (state: cuckoostore.stateInfo, newNumber: number) {
state.appStatus.settings.maximumNumberOfColumnsInMultiLineMode = newNumber
localStorage.setItem('maximumNumberOfColumnsInMultiLineMode', JSON.stringify(newNumber))
},
updateAutoExpandSpoilerTextMode (state: cuckoostore.stateInfo, newMode: boolean) {
state.appStatus.settings.autoExpandSpoilerTextMode = newMode
localStorage.setItem('autoExpandSpoilerTextMode', JSON.stringify(newMode))
},
updateIsEditingThemeMode (state: cuckoostore.stateInfo, newMode: boolean) {
state.appStatus.isEditingThemeMode = newMode
state.appStatus.shouldShowThemeEditPanel = newMode
},
updateShouldShowThemeEditPanel (state: cuckoostore.stateInfo, show: boolean) {
state.appStatus.shouldShowThemeEditPanel = show
}
}
================================================
FILE: src/store/mutations/index.ts
================================================
import Vue from 'vue'
import timelinesMutations from './timelines'
import notificationsMutations from './notifications'
import appStatusMutations from './appstatus'
import { cuckoostore, mastodonentities } from '@/interface'
import { formatHtml, formatAccountDisplayName } from '@/util'
function formatStatusContent (status: mastodonentities.Status) {
return formatHtml(status.content, { externalEmojis: status.emojis })
}
const oAuthInfoMutations = {
clearAllOAuthInfo (state: cuckoostore.stateInfo) {
state.OAuthInfo.clientId = ''
state.OAuthInfo.clientSecret = ''
state.OAuthInfo.code = ''
state.OAuthInfo.accessToken = ''
localStorage.clear()
},
updateClientInfo (state: cuckoostore.stateInfo, { clientId, clientSecret }) {
state.OAuthInfo.clientId = clientId
state.OAuthInfo.clientSecret = clientSecret
localStorage.setItem('clientId', clientId)
localStorage.setItem('clientSecret', clientSecret)
},
updateOAuthCode (state: cuckoostore.stateInfo, code: string) {
state.OAuthInfo.code = code
localStorage.setItem('code', code)
},
updateOAuthAccessToken (state: cuckoostore.stateInfo, accessToken: string) {
state.OAuthInfo.accessToken = accessToken
localStorage.setItem('accessToken', accessToken)
}
}
const statusesMutations = {
updateStatusMap (state: cuckoostore.stateInfo, newStatusMap) {
Object.keys(newStatusMap).forEach(statusId => {
// format content
const newStatus: mastodonentities.Status = newStatusMap[statusId]
newStatus.content = formatStatusContent(newStatus)
// format reblog content
if (newStatus.reblog) newStatus.reblog.content = formatStatusContent(newStatus.reblog)
// format spoiler text
if (newStatus.spoiler_text) newStatus.spoiler_text = formatHtml(newStatus.spoiler_text, { externalEmojis: newStatus.emojis })
// format account display name
newStatus.account.display_name = formatAccountDisplayName(newStatus.account)
// fix favourited and reblogged count sync bug
const checkTarget = newStatus.reblog || newStatus
if (checkTarget.favourited && checkTarget.favourites_count === 0) checkTarget.favourites_count = 1
if (checkTarget.reblogged && checkTarget.reblogs_count === 0) checkTarget.reblogs_count = 1
Vue.set(state.statusMap, statusId, newStatusMap[statusId])
})
},
removeStatusFromStatusMapById (state: cuckoostore.stateInfo, statusId: string) {
Vue.set(state.statusMap, statusId, undefined)
},
updateFavouriteStatusById (state: cuckoostore.stateInfo, { favourited, mainStatusId, targetStatusId, notSelfOperate }) {
let targetStatus
if (mainStatusId === targetStatusId) {
targetStatus = state.statusMap[targetStatusId]
} else {
targetStatus = state.statusMap[mainStatusId].reblog
}
if (!targetStatus) return
if (!notSelfOperate) {
Vue.set(targetStatus, 'favourited', favourited)
}
Vue.set(targetStatus, 'favourites_count', favourited ?
targetStatus.favourites_count + 1 : targetStatus.favourites_count - 1)
},
updateReblogStatusById (state: cuckoostore.stateInfo, { reblogged, mainStatusId, targetStatusId, notSelfOperate }) {
let targetStatus
if (mainStatusId === targetStatusId) {
targetStatus = state.statusMap[targetStatusId]
} else {
targetStatus = state.statusMap[mainStatusId].reblog
}
if (!targetStatus) return
if (!notSelfOperate) {
Vue.set(targetStatus, 'reblogged', reblogged)
}
Vue.set(targetStatus, 'reblogs_count', reblogged ?
targetStatus.reblogs_count + 1 : targetStatus.reblogs_count - 1)
}
}
const mutations = {
updateMastodonServerUri (state: cuckoostore.stateInfo, mastodonServerUri: string) {
state.mastodonServerUri = mastodonServerUri
localStorage.setItem('mastodonServerUri', mastodonServerUri)
},
updateCurrentUserAccount (state: cuckoostore.stateInfo, currentUserAccount: mastodonentities.Account) {
currentUserAccount.display_name = formatAccountDisplayName(currentUserAccount)
state.currentUserAccount = currentUserAccount
localStorage.setItem('currentUserAccount', JSON.stringify(currentUserAccount))
},
updateCustomEmojis (state: cuckoostore.stateInfo, customEmojis: Array) {
state.customEmojis = customEmojis
localStorage.setItem('customEmojis', JSON.stringify(customEmojis))
},
updateContextMap (state: cuckoostore.stateInfo, newContextMap) {
Object.keys(newContextMap).forEach(statusId => {
Vue.set(state.contextMap, statusId, newContextMap[statusId])
})
},
updateCardMap (state: cuckoostore.stateInfo, newCardMap) {
Object.keys(newCardMap).forEach(statusId => {
Vue.set(state.cardMap, statusId, newCardMap[statusId])
})
},
...oAuthInfoMutations,
...timelinesMutations,
...statusesMutations,
...appStatusMutations,
...notificationsMutations
}
export default mutations
================================================
FILE: src/store/mutations/notifications.ts
================================================
import Vue from 'vue'
import { cuckoostore, mastodonentities } from '@/interface'
import { formatAccountDisplayName, formatHtml } from '@/util'
export default {
unShiftNotification (state: cuckoostore.stateInfo, newNotifications: Array) {
newNotifications.forEach(notification => {
if (notification.account) {
notification.account.display_name = formatAccountDisplayName(notification.account)
}
if (notification.status) {
notification.status.content = formatHtml(notification.status.content, { externalEmojis: notification.status.emojis })
}
})
state.notifications = newNotifications.concat(state.notifications)
},
pushNotifications (state: cuckoostore.stateInfo, newNotifications: Array) {
state.notifications = state.notifications.concat(newNotifications)
},
updateRelationships (state: cuckoostore.stateInfo, newRelationships: { [id: string]: mastodonentities.Relationship }) {
Object.keys(newRelationships).forEach(id => {
Vue.set(state.relationships, id, newRelationships[id])
})
}
}
================================================
FILE: src/store/mutations/timelines.ts
================================================
import Vue from 'vue'
import { cuckoostore } from '@/interface'
import { TimeLineTypes } from '@/constant'
import { isBaseTimeLine } from '@/util'
export default {
setTimeLineStatuses (state: cuckoostore.stateInfo, { newStatusIdList, timeLineType, hashName }) {
if (isBaseTimeLine(timeLineType)) {
Vue.set(state.timelines, timeLineType, newStatusIdList)
} else {
if (!hashName) throw new Error('need a hash name!')
Vue.set(state.timelines[timeLineType], hashName, newStatusIdList)
}
},
pushTimeLineStatuses (state: cuckoostore.stateInfo, { newStatusIdList, timeLineType, hashName }) {
let targetTimeLines
if (isBaseTimeLine(timeLineType)) {
targetTimeLines = state.timelines[timeLineType]
} else {
if (!hashName) throw new Error('need a hash name!')
targetTimeLines = state.timelines[timeLineType][hashName]
}
newStatusIdList = newStatusIdList.filter(id => {
return targetTimeLines.indexOf(id) === -1
})
targetTimeLines.push(...newStatusIdList)
},
unShiftTimeLineStatuses (state: cuckoostore.stateInfo, { newStatusIdList, timeLineType, hashName }) {
let targetTimeLines
if (isBaseTimeLine(timeLineType)) {
targetTimeLines = state.timelines[timeLineType]
} else {
if (!hashName) throw new Error('need a hash name!')
targetTimeLines = state.timelines[timeLineType][hashName]
}
newStatusIdList = newStatusIdList.filter(id => {
return targetTimeLines.indexOf(id) === -1
})
targetTimeLines.unshift(...newStatusIdList)
},
deleteStatusFromTimeLine (state: cuckoostore.stateInfo, statusId: string) {
Object.keys(state.timelines).forEach(timeLineType => {
if (isBaseTimeLine(timeLineType)) {
const currentTimeLineList = state.timelines[timeLineType]
if (currentTimeLineList) {
currentTimeLineList.splice(currentTimeLineList.indexOf(statusId), 1)
}
} else {
const currentTimeLineMap = state.timelines[timeLineType]
Object.keys(currentTimeLineMap).forEach(hashName => {
const currentTimeLineList = currentTimeLineMap[hashName]
if (currentTimeLineList) {
currentTimeLineList.splice(currentTimeLineList.indexOf(statusId), 1)
}
})
}
})
}
}
================================================
FILE: src/themes/basecolor.ts
================================================
export default {
'@primaryColor': '#2196f3',
'@secondaryColor': '#ff4081',
'@successColor': '#4caf50',
'@warningColor': '#fdd835',
'@infoColor': '#2196f3',
'@errorColor': '#f44336',
'@alternateTextColor': '#ffffff',
'@trackColor': '#9e9e9e',
'@textColor': 'rgba(0,0,0,.87)',
'@secondaryTextColor': 'rgba(0,0,0,.54)',
'@disabledColor': 'rgba(0,0,0,.38)',
'@backgroundColor': '#f1f1f1',
'@dialogBackgroundColor': '#fff'
}
================================================
FILE: src/themes/index.ts
================================================
// @ts-ignore
import cuckooHubTheme from './presets/cuckoohub'
import greenLightTheme from './presets/greenlight'
import darkTheme from './presets/dark'
import googlePlusTheme from './presets/googleplus'
import * as less from 'less'
import stylePattern from './stylepattern'
import { ThemeNames } from '@/constant'
import * as fileSaver from 'file-saver'
import baseColor from './basecolor'
const presetThemeInfo = {
[ThemeNames.GOOGLE_PLUS]: {
theme: googlePlusTheme,
less: stylePattern(Object.assign({}, baseColor, googlePlusTheme.colorSet)),
css: null,
},
[ThemeNames.DARK]: {
theme: darkTheme,
less: stylePattern(Object.assign({}, baseColor, darkTheme.colorSet)),
css: null,
},
[ThemeNames.GREEN_LIGHT]: {
theme: greenLightTheme,
less: stylePattern(Object.assign({}, baseColor, greenLightTheme.colorSet)),
css: null
},
[ThemeNames.CUCKOO_HUB]: {
theme: cuckooHubTheme,
less: stylePattern(Object.assign({}, baseColor, cuckooHubTheme.colorSet)),
css: null
}
}
class ThemeManager {
public get themeInfo () {
return Object.assign({}, presetThemeInfo, this.customThemeInfo)
}
private customThemeInfo = localStorage.getItem('customThemeInfo') ? JSON.parse(localStorage.getItem('customThemeInfo')) : {}
private getThemeStyleElem (): HTMLStyleElement {
const themeElemId = 'cuckoo-plus-theme'
let styleElem = document.getElementById(themeElemId)
if (styleElem) return styleElem as HTMLStyleElement
styleElem = document.createElement('style')
styleElem.id = themeElemId
document.body.appendChild(styleElem)
return styleElem as HTMLStyleElement
}
private setFavIconByThemeName (themeName: string) {
Array.from(document.head.querySelectorAll('link')).forEach(el => {
if (el.getAttribute('rel') === 'icon') {
const size = el.getAttribute('sizes')
if (size) {
el.setAttribute('href', `favicon/${this.themeInfo[themeName].theme.toFavIconPath}/${size}.png`)
}
}
})
}
private setThemeColorByThemeName (themeName: string) {
Array.from(document.head.querySelectorAll('meta')).find(el => {
return el.getAttribute('name') === 'theme-color'
}).setAttribute('content', this.themeInfo[themeName].theme.colorSet['@primaryColor'])
}
private setThemeCssByThemeName (themeName: string) {
// todo fix custom localStorage data error
if (!this.themeInfo[themeName].less || this.customThemeInfo[themeName]) {
this.themeInfo[themeName].less = stylePattern(Object.assign({}, baseColor, this.themeInfo[themeName].theme.colorSet))
}
if (this.themeInfo[themeName].css) {
this.getThemeStyleElem().innerHTML = this.themeInfo[themeName].css
} else {
less.render(this.themeInfo[themeName].less).then(output => {
this.getThemeStyleElem().innerHTML = output.css
this.themeInfo[themeName].css = output.css
})
}
}
private addCustomThemeInfo (themeColorSet, themeName) {
this.customThemeInfo[themeName] = {
theme: { colorSet: themeColorSet, toFavIconPath: 'google_plus' },
less: stylePattern(Object.assign({}, baseColor, themeColorSet)),
css: null
}
this.updateLocalStorageData()
}
private deleteCustomThemeInfo (themeName) {
delete this.customThemeInfo[themeName]
this.updateLocalStorageData()
}
private updateLocalStorageData () {
const customThemeInfo = {}
Object.keys(this.customThemeInfo).forEach(themeName => {
customThemeInfo[themeName] = {
theme: { colorSet: this.customThemeInfo[themeName].theme.colorSet, toFavIconPath: 'google_plus' }
}
})
localStorage.setItem('customThemeInfo', JSON.stringify(customThemeInfo))
}
public getThemeInfoByThemeName (themeName: string) {
if (!this.themeInfo[themeName]) return this.themeInfo[ThemeNames.GOOGLE_PLUS]
return this.themeInfo[themeName]
}
public getThemeOptionsList () {
return Object.keys(this.themeInfo)
.filter(themeName => typeof this.themeInfo[themeName] === 'object')
.map(themeName => { return { 'value': themeName } })
}
public getCustomThemeOptionsList () {
return Object.keys(this.customThemeInfo)
.filter(themeName => typeof this.themeInfo[themeName] === 'object')
.map(themeName => { return { 'value': themeName } })
}
public setTheme (themeName: string) {
if (!this.themeInfo[themeName]) {
themeName = ThemeNames.GOOGLE_PLUS
}
this.setThemeCssByThemeName(themeName)
this.setFavIconByThemeName(themeName)
this.setThemeColorByThemeName(themeName)
}
public exportTheme (themeName: string) {
const blob = new Blob([JSON.stringify(this.themeInfo[themeName].theme.colorSet)], {type: "text/plain;charset=utf-8"});
fileSaver.saveAs(blob, `${themeName}.json`);
}
public importTheme (themeColorSet, themeName: string) {
this.addCustomThemeInfo(themeColorSet, themeName)
}
public deleteTheme (themeName: string) {
this.deleteCustomThemeInfo(themeName)
}
public setTempThemeByColorSet (colorSet) {
const finalColorSet = Object.assign({}, baseColor, colorSet)
less.render(stylePattern(finalColorSet)).then(output => {
this.getThemeStyleElem().innerHTML = output.css
})
}
}
export default new ThemeManager()
================================================
FILE: src/themes/presets/cuckoohub.ts
================================================
import darkTheme from './dark'
const colorSet = Object.assign({}, darkTheme.colorSet, {
'@primaryColor': '#FF9900',
'@secondaryColor': '#FF9900',
'@textColor': '#fff',
'@secondaryTextColor': '#666',
'@backgroundColor': '#000',
'@dialogBackgroundColor': '#1b1b1b'
})
export default {
colorSet,
toFavIconPath: 'cuckoo_hub'
}
================================================
FILE: src/themes/presets/dark.ts
================================================
const colorSet = {
'@primaryColor': '#1976d2',
'@secondaryColor': '#ff4081',
'@trackColor': '#444b5d',
'@textColor': 'rgba(255, 255, 255, 0.85)',
'@secondaryTextColor': '#606984',
'@disabledColor': 'rgba(255, 255, 255, 0.3)',
'@backgroundColor': '#191b22',
'@dialogBackgroundColor': '#282c37'
}
export default {
colorSet,
toFavIconPath: 'dark'
}
================================================
FILE: src/themes/presets/googleplus.ts
================================================
const colorSet = {
'@primaryColor': '#db4437',
'@secondaryColor': '#2b90d9',
'@trackColor': '#bdbdbd',
'@textColor': 'rgba(0,0,0,.87)',
'@secondaryTextColor': 'rgba(0,0,0,.54)',
'@disabledColor': 'rgba(0,0,0,.38)',
'@backgroundColor': '#f1f1f1',
'@dialogBackgroundColor': '#fff'
}
export default {
colorSet,
toFavIconPath: 'google_plus'
}
================================================
FILE: src/themes/presets/greenlight.ts
================================================
import googlePlusTheme from './googleplus'
const colorSet = Object.assign({}, googlePlusTheme.colorSet, {
'@primaryColor': '#0f9d58',
'@secondaryColor': '#0f9d58'
})
export default {
colorSet,
toFavIconPath: 'green_light'
}
================================================
FILE: src/themes/stylepattern.ts
================================================
const themeColorLessText = `
.mu-primary-color {
background-color: @primaryColor;
}
.mu-secondary-color {
background-color: @secondaryColor;
}
.mu-success-color {
background-color: @successColor;
}
.mu-warning-color {
background-color: @warningColor;
}
.mu-info-color {
background-color: @infoColor;
}
.mu-error-color {
background-color: @errorColor;
}
.mu-inverse {
color: @alternateTextColor;
}
.mu-primary-text-color {
color: @primaryColor;
}
.mu-secondary-text-color {
color: @secondaryColor;
}
.mu-success-text-color {
color: @successColor;
}
.mu-warning-text-color {
color: @warningColor;
}
.mu-info-text-color {
color: @infoColor;
}
.mu-error-text-color {
color: @errorColor;
}
`
const appColorLessText = `
body {
background-color: @backgroundColor;
}
a {
color: @secondaryColor;
}
// class for certain component
.status-card {
.operate-btn-group {
.count {
color: @textColor;
}
}
}
.circle-btn {
width: 36px;
height: 36px;
border-radius: 50%;
cursor: pointer;
-webkit-transition: background .3s;
-moz-transition: background .3s;
-ms-transition: background .3s;
-o-transition: background .3s;
transition: background .3s;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
margin: 0 8px;
font-size: 15px;
background-color: @backgroundColor;
color: @textColor;
&.disabled {
cursor: not-allowed !important;
&:hover {
box-shadow: none;
}
}
&:hover {
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.26);
}
&.primary-theme-bg-color {
background-color: @primaryColor;
}
&.unset-display {
display: unset;
}
&.hover:before {
background-color: unset;
}
}
.header-svg-fill {
fill: @secondaryTextColor;
}
.auto-size-text-area {
width: 100%;
font-size: 15px;
line-height: 18px;
outline: none;
border: none;
padding: 0;
resize: none;
background-color: @dialogBackgroundColor;
color: @textColor;
}
.cuckoo-header-container {
.mu-text-field-input {
color: #fff;
}
}
.delete-hash-btn {
color: @textColor !important;
}
.media-area {
height: 212px;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
white-space: nowrap;
.media-item {
margin-right: 8px;
position: relative;
display: inline-block;
height: 100%;
img {
width: auto;
height: 100%;
display: block;
}
}
&.single-media-area {
.media-item {
margin: 0;
width: 100%;
display: flex;
justify-content: center;
}
img {
width: 100%;
}
}
}
.media-area {
.media-item {
}
}
// for overwrite muse-ui style
.mu-dialog {
background-color: @dialogBackgroundColor;
.mu-dialog-title {
color: @textColor;
}
.mu-dialog-body {
height: 100%;
color: @textColor;
}
}
.mu-card {
color: @textColor;
background-color: @dialogBackgroundColor;
-webkit-box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.14);
-moz-box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.14);
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.14);
.mu-card-text {
color: @textColor;
}
.mu-card-header-title {
.mu-card-title {
color: @textColor;
}
.mu-card-sub-title {
color: @secondaryTextColor;
}
}
}
.mu-input {
color: @secondaryTextColor;
.mu-select-content {
color: @textColor;
.mu-select-input {
color: @textColor;
}
}
}
.mu-popover {
background-color: @dialogBackgroundColor;
.mu-list {
padding: 0;
}
}
.mu-option {
color: @textColor;
&.is-selected .mu-item {
color: @primaryColor;
}
}
.mu-flat-button.disabled {
color: @disabledColor;
}
.mu-item {
color: @textColor;
.mu-item-action {
color: @textColor;
}
&.is-selected {
color: @primaryColor;
.mu-item-action {
color: @primaryColor;
}
}
}
.mu-loading-wrap {
background-color: fade(@dialogBackgroundColor, 80%) !important;
color: @textColor;
}
.mu-linear-progress {
.mu-linear-progress-background, .mu-linear-progress-determinate {
background-color: @secondaryColor !important;
}
}
.mu-text-field-input {
color: @textColor;
&::placeholder {
color: @trackColor;
font-weight: 400;
}
}
.mu-switch-checked {
color: @primaryColor;
}
.mu-form-item__focus {
color: @secondaryColor;
}
.mu-chip.mu-primary-color {
background-color: @primaryColor;
}
.mu-circle-spinner {
border-color: @primaryColor;
}
.notification-content {
a {
color: @textColor;
}
}
// class for mixin
.primary-theme-bg-color {
background-color: @primaryColor !important;
> * {
color: @alternateTextColor;
}
}
.secondary-theme-bg-color {
background-color: @secondaryColor;
}
.default-theme-bg-color {
background-color: @backgroundColor;
}
.primary-theme-text-color {
color: @primaryColor;
}
.secondary-theme-text-color {
color: @secondaryColor;
}
.primary-read-text-color {
color: @textColor;
}
.secondary-read-text-color {
color: @secondaryTextColor;
}
.placeholder-read-text-color {
color: @trackColor;
}
.base-theme-bg-color {
background-color: @backgroundColor !important;
}
.dialog-theme-bg-color {
background-color: @dialogBackgroundColor;
}
`
import baseColor from './basecolor'
export default function (colorSet: Object) {
return Object.keys(baseColor).reduce((acc, cur) => {
return acc.replace(new RegExp(cur, 'g'), colorSet[cur])
}, themeColorLessText + appColorLessText)
}
================================================
FILE: src/util.ts
================================================
import store from '@/store'
import { TimeLineTypes, RoutersInfo, I18nTags, VisibilityTypes } from '@/constant'
import { Route } from "vue-router"
import Formatter from "./formatter"
import { mastodonentities } from "@/interface"
import * as _ from 'underscore'
import * as $ from 'jquery'
export function patchApiUri (uri: string): string {
const targetServerUri = store.state.mastodonServerUri || 'https://pawoo.net'
return `${targetServerUri}${uri}`
}
export function generateUniqueKey () {
const toReplacedString = 'xxxxxxxy-yyxx-xxyx-yyxx-xxyyxxxxxyyy'
return toReplacedString.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0,
v = c === 'x' ? r : (r & 0x3 | 0x8)
return v.toString(16)
})
}
export function isBaseTimeLine (timeLineType: string): boolean {
return [TimeLineTypes.HOME, TimeLineTypes.PUBLIC, TimeLineTypes.DIRECT, TimeLineTypes.LOCAL].indexOf(timeLineType) !== -1
}
export function getTimeLineTypeAndHashName (route: Route) {
let timeLineType = '', hashName = ''
if (route.name === RoutersInfo.defaulttimelines.name) {
timeLineType = route.params.timeLineType
}
else if (route.name === RoutersInfo.tagtimelines.name) {
timeLineType = TimeLineTypes.TAG
hashName = route.params.tagName
}
else if (route.name === RoutersInfo.listtimelines.name) {
timeLineType = TimeLineTypes.LIST
hashName = route.params.listName
}
return { timeLineType, hashName }
}
export function getTargetStatusesList (listMap, timeLineType, hashName) {
let targetStatusesList
if (isBaseTimeLine(timeLineType)) {
targetStatusesList = listMap[timeLineType]
} else {
targetStatusesList = listMap[timeLineType][hashName]
}
return targetStatusesList || []
}
const visibilityTypeToDescMap = {
[VisibilityTypes.PUBLIC]: {
descTag: I18nTags.common.status_visibility_public_desc,
icon: 'public'
},
[VisibilityTypes.UNLISTED]: {
descTag: I18nTags.common.status_visibility_unlisted_desc,
icon: 'lock_open'
},
[VisibilityTypes.PRIVATE]: {
descTag: I18nTags.common.status_visibility_private_desc,
icon: 'lock'
},
[VisibilityTypes.DIRECT]: {
descTag: I18nTags.common.status_visibility_direct_desc,
icon: 'email'
}
}
export function getVisibilityDescInfo (visibilityType: string) {
return visibilityTypeToDescMap[visibilityType]
}
export async function prepareRootStatus (status: mastodonentities.Status) {
const contextMap = store.state.contextMap
const statusMap = store.state.statusMap
if (!contextMap[status.id]) {
await store.dispatch('updateContextMap', status.id)
}
let targetStatus = status
const targetStatusContext = contextMap[status.id]
if (!targetStatusContext) return
if (targetStatusContext.ancestors.length) {
targetStatus = statusMap[targetStatusContext.ancestors[0]]
}
store.dispatch('updateContextMap', targetStatus.id)
return targetStatus
}
let formatter
export function formatHtml(html: string, options: { externalEmojis } = { externalEmojis: [] }): string {
if (!formatter) {
formatter = new Formatter(store.state.customEmojis)
}
formatter.updateCustomEmojiMap(options.externalEmojis)
// create a parent node to contain the input html
const parentNode = document.createElement('template')
parentNode.innerHTML = html
walkTextNodes(parentNode.content, (parentNode, textNode) => {
const spanNode = document.createElement('span')
spanNode.innerHTML = formatter.format(_.escape(textNode.textContent))
parentNode.replaceChild(spanNode, textNode)
})
return parentNode.innerHTML
}
export function formatAccountDisplayName (account: mastodonentities.Account) {
return formatHtml(store.getters['getAccountDisplayName'](account), { externalEmojis: account.emojis })
}
export function extractText(html: string): string {
let text = ""
// create a parent node to contain the input html
const parentNode = document.createElement('template')
parentNode.innerHTML = html
walkTextNodes(parentNode.content, (parentNode, textNode) => {
text += (textNode.textContent + " ")
})
return text
}
const maxImageSize = 7.8 * 1024 * 1024
export async function resetImageFileSizeForUpload (file: File) {
if (file.size < maxImageSize) return new Promise(r => r(file))
const oldImage = new Image()
oldImage.src = window.URL.createObjectURL(file)
// todo set to 1280 width for now
const newWidth = 1280
return new Promise(resolve => {
oldImage.onload = () => {
const newHeight = oldImage.height * newWidth / oldImage.width
const canvas = document.createElement('canvas')
const canvasContext = canvas.getContext('2d')
canvas.width = newWidth
canvas.height = newHeight
canvasContext.drawImage(oldImage, 0, 0, newWidth, newHeight)
canvas.toBlob((blob) => {
resolve(blob)
})
}
})
}
function walkTextNodes(node, textNodeHandler) {
if (node) {
for (let i = 0; i < node.childNodes.length; ++i) {
const childNode = node.childNodes[i]
if (childNode.nodeType === 3) {
textNodeHandler(node, childNode)
} else if (childNode.nodeType === 1 || childNode.nodeType === 9 || childNode.nodeType === 11) {
walkTextNodes(childNode, textNodeHandler)
}
}
}
}
function easeInOutQuad (t, b, c, d) {
t /= d/2
if (t < 1) return c/2*t*t + b
t--
return -c/2 * (t*(t-2) - 1) + b
}
const requestAnimFrame = (function(){return window.requestAnimationFrame||window.webkitRequestAnimationFrame||function(callback){window.setTimeout(callback,1000/60);};})()
export function animatedScrollTo (element: HTMLElement, to: number, duration: number, callback?) {
const start = element.scrollTop,
change = to - start,
animationStart = +new Date()
let animating = true
let lastpos = null
const animateScroll = function() {
if (!animating) return
requestAnimFrame(animateScroll)
const now = +new Date()
const val = Math.floor(easeInOutQuad(now - animationStart, start, change, duration))
lastpos = val
element.scrollTop = val
if (now > animationStart + duration) {
element.scrollTop = to
animating = false
if (callback) { callback() }
}
}
requestAnimFrame(animateScroll)
}
export function getNetEaseMusicFrameLinkFromContentLink (link: string): string | void {
const url = new URL(link)
const isNetEaseMusic = url.host === 'music.163.com'
if (!isNetEaseMusic) return
let songId
const isUseSongPath = url.pathname.startsWith('/song')
if (isUseSongPath) {
// use param song id
if (url.searchParams.get('id')) {
songId = url.searchParams.get('id')
}
// use path song id
if (url.pathname.replace('/song', '').match(/\d+/)) {
songId = url.pathname.replace('/song', '').match(/\d+/)[0]
}
}
const isUseSongHash = url.hash.startsWith('#/song?')
if (isUseSongHash) {
const paramsList = url.hash.replace('#/song?', '').split('&').filter(anchor => anchor.startsWith('id='))
if (paramsList[0]) songId = paramsList[0].split('=')[1]
}
if (!songId) return
return `//music.163.com/outchain/player?type=2&id=${songId}&auto=0&height=66`
}
export function getYoutubeVideoFrameLinkFromContentLink (link: string): string | void {
const url = new URL(link)
let v
const isShareLink = url.host === 'youtu.be'
if (isShareLink) {
v = url.pathname.slice(1)
}
const isBrowserLink = url.host === 'www.youtube.com'
if (isBrowserLink) {
v = url.searchParams.get('v')
}
if (!v) return
return `https://www.youtube.com/embed/${v}`
// if (!link.startsWith('https://www.youtube.com/watch')) return
//
// const url = new URL(link)
//
// if (!url.searchParams.has('v')) return
//
// const v = url.searchParams.get('v')
// return `https://www.youtube.com/embed/${v}`
}
export const documentGlobalEventBus = new class {
private eventMap: {
[key: string]: Array<{
listener: Function,
skip?: boolean
}>
} = {}
on (eventName: string, eventListener: Function, coexistWithOtherListener: boolean = false) {
if (!this.eventMap[eventName]) {
this.eventMap[eventName] = []
this.initDocumentGlobalEvent(eventName)
}
if (!coexistWithOtherListener) {
this.eventMap[eventName].forEach(listenerInfo => {
listenerInfo.skip = true
})
}
this.eventMap[eventName].push({
listener: eventListener
})
}
off (eventName: string, eventListener: Function) {
if (!eventListener) {
this.eventMap[eventName] = []
}
if (!this.eventMap[eventName]) return
const targetIndex = this.eventMap[eventName].findIndex(listenerInfo => listenerInfo.listener === eventListener)
this.eventMap[eventName].splice(targetIndex, 1)
this.eventMap[eventName].forEach(listenerInfo => {
listenerInfo.skip = false
})
}
private initDocumentGlobalEvent (eventName: string) {
document.addEventListener(eventName, (e) => {
this.eventMap[eventName].forEach(listenerInfo => {
if (listenerInfo.skip) return
listenerInfo.listener(e)
})
})
}
}
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"baseUrl": ".",
"outDir": "./public/dist/",
"sourceMap": true,
"module": "commonjs",
"moduleResolution": "node",
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"target": "es2015",
"allowJs": true,
"paths": {
"@/*": ["src/*"]
},
"lib": ["es2015", "dom"]
},
"exclude": [
"node_modules"
]
}
================================================
FILE: webpack.config.js
================================================
const webpack = require('webpack')
const path = require('path')
const yargs = require('yargs')
const fs = require('fs')
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
const MinifyPlugin = require("babel-minify-webpack-plugin");
let { env } = yargs.argv
if (!env) env = 'develop'
const isEnvProduction = env === 'production'
const plugins = [
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(env)
}),
]
if (isEnvProduction) {
// remove source map data
fs.unlink(path.join(__dirname, './public/dist/bundle.js.map'), (err) => {})
// plugins.push(new MinifyPlugin())
} else {
// plugins.push(new BundleAnalyzerPlugin())
}
module.exports = {
devServer: {
contentBase: path.join(__dirname, '/public'),
publicPath: '/dist/',
compress: true,
port: 3000,
host: "0.0.0.0",
watchContentBase: true,
disableHostCheck: true
},
entry: './src/index.ts',
devtool: isEnvProduction ? '' : '#source-map',
output: {
libraryExport: 'default',
path: path.resolve(__dirname, 'public/dist/'),
filename: 'bundle.js'
},
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
loaders: {
// Since sass-loader (weirdly) has SCSS as its default parse mode, we map
// the "scss" and "sass" values for the lang attribute to the right configs here.
// other preprocessors should work out of the box, no loader config like this necessary.
'scss': 'vue-style-loader!css-loader!sass-loader',
'sass': 'vue-style-loader!css-loader!sass-loader?indentedSyntax',
'i18n': '@kazupon/vue-i18n-loader'
},
esModule: true
}
},
{
test: /\.ts$/,
exclude: /node_modules/,
use: {
loader: 'ts-loader',
options: {
appendTsSuffixTo: [/\.vue$/]
}
}
},
{
test: /\.tsx$/,
exclude: /node_modules/,
use: [
'babel-loader',
'ts-loader'
]
},
{
test: /\.(png|jpg|gif|svg)$/,
loader: 'file-loader',
options: {
name: '../assets/images/[name].[ext]'
}
},
{
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
loader: 'url-loader',
options: {
limit: 1024,
name: '../assets/fonts/[name].[ext]'
}
},
{
test: /\.css$/,
use: [{
loader: "style-loader"
}, {
loader: "css-loader"
}]
},
{
test: /\.less$/,
use: [{
loader: "style-loader"
}, {
loader: "css-loader"
}, {
loader: "less-loader"
}]
}
]
},
resolve: {
extensions: ['.ts', '.js', '.vue'],
alias: {
'@': path.join(__dirname, '/src')
}
},
externals: {
'moment': 'moment',
'underscore': '_'
// todo muse ui has bug
},
plugins: plugins
};