├── .eslintignore ├── _config.yml ├── icon.psd ├── static ├── icons │ ├── 16.png │ ├── 19.png │ ├── 38.png │ ├── 64.png │ └── 128.png └── assets │ └── download.css ├── .github ├── assets │ ├── icon.png │ ├── promo1.ai │ ├── promo1.png │ ├── click_icon1.png │ ├── click_icon2.png │ ├── screenshot1.png │ ├── screenshot2.png │ ├── screenshot3.png │ ├── small_promo.jpg │ ├── small_promo.psd │ └── tryitnowbutton_small.png └── CONTRIBUTING.md ├── assets └── background-1200x630.ai ├── src ├── tab │ ├── classes │ │ ├── User.js │ │ ├── Threads.js │ │ └── Thread.js │ ├── router │ │ └── index.js │ ├── components │ │ ├── download │ │ │ ├── Html.vue │ │ │ ├── Thread.vue │ │ │ ├── Event.vue │ │ │ └── Message.vue │ │ ├── ListPage.vue │ │ ├── BarChart.js │ │ ├── ThreadList │ │ │ ├── DetailTemplate │ │ │ │ ├── EmojiChooserDropdown.vue │ │ │ │ ├── ColorChooserDropdown.vue │ │ │ │ ├── MuteUntil.vue │ │ │ │ ├── Chooser.vue │ │ │ │ └── index.vue │ │ │ ├── OperationButton.vue │ │ │ ├── NameTemplate │ │ │ │ ├── index.vue │ │ │ │ └── ThreadName.vue │ │ │ ├── Avatar.vue │ │ │ └── index.vue │ │ ├── AboutPage.vue │ │ ├── SharingDialog.vue │ │ └── ChartPage.vue │ ├── README.md │ ├── lib │ │ ├── shareOnFb.js │ │ ├── downloadMessages.js │ │ ├── fetchThread.js │ │ ├── generateCanvas.js │ │ ├── fetchThreads.js │ │ ├── changeThreadSetting.js │ │ ├── util.js │ │ └── fetchThreadDetail.js │ ├── root.vue │ └── index.js ├── content │ └── index.js ├── options │ ├── index.js │ └── root.vue ├── ext │ ├── storage.js │ └── Indexeddb.js ├── manifest.js ├── backend │ └── index.js └── _locales │ ├── zh_TW │ └── messages.js │ └── en │ └── messages.js ├── .stickler.yml ├── .postcssrc.js ├── .gitignore ├── element-variables.scss ├── .babelrc ├── core ├── .env.example.js ├── page.ejs ├── webpack.dev.js ├── webpack.beta.js ├── webpack.prod.js ├── deploy.js ├── tools.js └── webpack.base.js ├── .eslintrc.js ├── plugins └── GenerateLocaleJsonPlugin.js ├── README-zh-TW.md ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | build/*.js 2 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /icon.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ALiangLiang/Counter-for-Messenger/HEAD/icon.psd -------------------------------------------------------------------------------- /static/icons/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ALiangLiang/Counter-for-Messenger/HEAD/static/icons/16.png -------------------------------------------------------------------------------- /static/icons/19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ALiangLiang/Counter-for-Messenger/HEAD/static/icons/19.png -------------------------------------------------------------------------------- /static/icons/38.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ALiangLiang/Counter-for-Messenger/HEAD/static/icons/38.png -------------------------------------------------------------------------------- /static/icons/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ALiangLiang/Counter-for-Messenger/HEAD/static/icons/64.png -------------------------------------------------------------------------------- /static/icons/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ALiangLiang/Counter-for-Messenger/HEAD/static/icons/128.png -------------------------------------------------------------------------------- /.github/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ALiangLiang/Counter-for-Messenger/HEAD/.github/assets/icon.png -------------------------------------------------------------------------------- /.github/assets/promo1.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ALiangLiang/Counter-for-Messenger/HEAD/.github/assets/promo1.ai -------------------------------------------------------------------------------- /.github/assets/promo1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ALiangLiang/Counter-for-Messenger/HEAD/.github/assets/promo1.png -------------------------------------------------------------------------------- /assets/background-1200x630.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ALiangLiang/Counter-for-Messenger/HEAD/assets/background-1200x630.ai -------------------------------------------------------------------------------- /.github/assets/click_icon1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ALiangLiang/Counter-for-Messenger/HEAD/.github/assets/click_icon1.png -------------------------------------------------------------------------------- /.github/assets/click_icon2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ALiangLiang/Counter-for-Messenger/HEAD/.github/assets/click_icon2.png -------------------------------------------------------------------------------- /.github/assets/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ALiangLiang/Counter-for-Messenger/HEAD/.github/assets/screenshot1.png -------------------------------------------------------------------------------- /.github/assets/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ALiangLiang/Counter-for-Messenger/HEAD/.github/assets/screenshot2.png -------------------------------------------------------------------------------- /.github/assets/screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ALiangLiang/Counter-for-Messenger/HEAD/.github/assets/screenshot3.png -------------------------------------------------------------------------------- /.github/assets/small_promo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ALiangLiang/Counter-for-Messenger/HEAD/.github/assets/small_promo.jpg -------------------------------------------------------------------------------- /.github/assets/small_promo.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ALiangLiang/Counter-for-Messenger/HEAD/.github/assets/small_promo.psd -------------------------------------------------------------------------------- /src/tab/classes/User.js: -------------------------------------------------------------------------------- 1 | export default class User { 2 | constructor (data) { 3 | Object.assign(this, data) 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.github/assets/tryitnowbutton_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ALiangLiang/Counter-for-Messenger/HEAD/.github/assets/tryitnowbutton_small.png -------------------------------------------------------------------------------- /src/content/index.js: -------------------------------------------------------------------------------- 1 | // submit a message about submit. means "login" 2 | document.addEventListener('submit', function () { 3 | chrome.runtime.sendMessage({}) 4 | }) 5 | -------------------------------------------------------------------------------- /.stickler.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | eslint: 3 | config: .eslintrc.js 4 | fixer: true 5 | files: 6 | ignore: 7 | - 'bower_components/*' 8 | - 'node_modules/*' 9 | -------------------------------------------------------------------------------- /.postcssrc.js: -------------------------------------------------------------------------------- 1 | // https://github.com/michael-ciniawsky/postcss-load-config 2 | 3 | module.exports = { 4 | "plugins": { 5 | // to edit target browsers: use "browserslist" field in package.json 6 | "autoprefixer": {} 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # env 2 | core/.env.* 3 | !core/.env.example.* 4 | 5 | # IDE 6 | .vscode 7 | 8 | # dependencies 9 | node_modules 10 | package-lock.json 11 | 12 | # logs 13 | npm-debug.log 14 | yarn-error.log 15 | 16 | # Backpack build 17 | build 18 | 19 | # package zip 20 | beta-*.zip 21 | release-*.zip 22 | -------------------------------------------------------------------------------- /element-variables.scss: -------------------------------------------------------------------------------- 1 | /* theme color */ 2 | $--color-primary: #0084ff !default; 3 | $--color-success: #4bcc1f !default; 4 | $--color-danger: #f03c24 !default; 5 | 6 | /* icon font path, required */ 7 | $--font-path: '~element-ui/lib/theme-chalk/fonts'; 8 | 9 | @import "~element-ui/packages/theme-chalk/src/index"; 10 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { 4 | "modules": false, 5 | "targets": { 6 | "browsers": ["> 1%", "last 2 versions", "not ie <= 8"] 7 | } 8 | }] 9 | ], 10 | "plugins": ["@babel/transform-runtime", "transform-vue-jsx"], 11 | "env": { 12 | "test": { 13 | "presets": ["@babel/preset-env"], 14 | "plugins": ["istanbul"] 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/tab/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Router from 'vue-router' 3 | import AboutPage from '../components/AboutPage' 4 | import ListPage from '../components/ListPage' 5 | import ChartPage from '../components/ChartPage' 6 | 7 | Vue.use(Router) 8 | 9 | /* eslint-disable no-multi-spaces */ 10 | export default new Router({ 11 | routes: [ 12 | { path: '/', redirect: '/list' }, 13 | { path: '/about', name: 'About', component: AboutPage }, 14 | { path: '/list', name: 'ListPage', component: ListPage }, 15 | { path: '/chart', name: 'ChartPage', component: ChartPage } 16 | ] 17 | }) 18 | -------------------------------------------------------------------------------- /src/options/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { Form, FormItem, Switch } from 'element-ui' 3 | import 'element-ui/lib/theme-chalk/index.css' 4 | import enLocale from 'element-ui/lib/locale/lang/en' 5 | import zhLocale from 'element-ui/lib/locale/lang/zh-TW' 6 | import locale from 'element-ui/lib/locale' 7 | import root from './root.vue' 8 | 9 | Vue.config.productionTip = false 10 | 11 | const mainLangName = chrome.i18n.getUILanguage().split('-')[0] 12 | locale.use((mainLangName === 'zh') ? zhLocale : enLocale) 13 | 14 | Vue.use(Form, { locale }) 15 | Vue.use(FormItem, { locale }) 16 | Vue.use(Switch, { locale }) 17 | 18 | /* eslint-disable no-new */ 19 | new Vue({ 20 | el: '#root', 21 | render: h => h(root) 22 | }) 23 | -------------------------------------------------------------------------------- /core/.env.example.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | release:{ 3 | extensionId: 'ecnglinljpjkbgmdpeiglonddahpbkea', 4 | clientId: 'xxxxxxxxxx', 5 | clientSecret: 'xxxxxxxxxx', 6 | refreshToken: 'xxxxxxxxxx', 7 | key: 'fasdaklgflgmfbwehfebfhabonsdn' // Optional 8 | }, 9 | beta:{ 10 | extensionId: 'ecnglinljpjkbgmdpeiglonddahpbkeb', 11 | clientId: 'xxxxxxxxxx', 12 | clientSecret: 'xxxxxxxxxx', 13 | refreshToken: 'xxxxxxxxxx', 14 | key: 'fasdaklgflgmfbwehfebfhabonsdn' // Optional 15 | }, 16 | fb: { 17 | id: '12346578901234657890', 18 | version: 'v2.12', 19 | domain: 'chrome.google.com', 20 | website: 'https://chrome.google.com/webstore/detail/ecnglinljpjkbgmdpeiglonddahpbkeb/' 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/ext/storage.js: -------------------------------------------------------------------------------- 1 | export default { 2 | get (key, defaultVal) { 3 | try { 4 | const result = JSON.parse(localStorage.getItem(key)) 5 | if (result) { 6 | return result 7 | } else { 8 | localStorage.setItem(key, JSON.stringify(defaultVal)) 9 | return defaultVal 10 | } 11 | } catch (e) { 12 | localStorage.setItem(key, JSON.stringify(defaultVal)) 13 | return defaultVal 14 | } 15 | }, 16 | set (key, val) { 17 | try { 18 | localStorage.setItem(key, JSON.stringify(val)) 19 | } catch (e) {} 20 | }, 21 | remove (key) { 22 | try { 23 | localStorage.removeItem(key) 24 | } catch (e) {} 25 | }, 26 | clear () { 27 | try { 28 | localStorage.clear() 29 | } catch (e) {} 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/tab/components/download/Html.vue: -------------------------------------------------------------------------------- 1 | 17 | 26 | -------------------------------------------------------------------------------- /src/tab/components/ListPage.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 31 | 32 | 38 | 39 | 46 | -------------------------------------------------------------------------------- /src/tab/components/BarChart.js: -------------------------------------------------------------------------------- 1 | import { HorizontalBar, mixins } from 'vue-chartjs' 2 | const { reactiveProp } = mixins 3 | 4 | export default { 5 | extends: HorizontalBar, 6 | 7 | mixins: [reactiveProp], 8 | 9 | props: ['chartData', 'options'], 10 | 11 | data () { 12 | return { 13 | canvas: this.$refs.canvas 14 | } 15 | }, 16 | 17 | mounted () { 18 | this.canvas = this.$refs.canvas 19 | this.renderChart(this.chartData, this.options) 20 | }, 21 | 22 | watch: { 23 | // By default, options are not reactive. So create a watcher for options.title.text 24 | // ref: https://github.com/apertureless/vue-chartjs/issues/106#issuecomment-299782906 25 | 'options.title.text' () { 26 | this.$data._chart.destroy() 27 | this.renderChart(this.chartData, this.options) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/tab/components/download/Thread.vue: -------------------------------------------------------------------------------- 1 | 18 | 28 | -------------------------------------------------------------------------------- /src/tab/components/download/Event.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 26 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // https://eslint.org/docs/user-guide/configuring 2 | 3 | module.exports = { 4 | root: true, 5 | parser: 'vue-eslint-parser', 6 | parserOptions: { 7 | parser: "babel-eslint", 8 | ecmaVersion: 2017, 9 | sourceType: 'module' 10 | }, 11 | globals: { 12 | chrome: true 13 | // chrome: true 14 | }, 15 | env: { 16 | browser: true, 17 | }, 18 | // https://github.com/standard/standard/blob/master/docs/RULES-en.md 19 | extends: ['plugin:vue/base', 'standard'], 20 | // required to lint *.vue files 21 | plugins: [ 22 | 'vue' 23 | ], 24 | // add your custom rules here 25 | rules: { 26 | // allow paren-less arrow functions 27 | 'arrow-parens': 0, 28 | // allow async-await 29 | 'generator-star-spacing': 0, 30 | // allow debugger during development 31 | 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/tab/components/ThreadList/DetailTemplate/EmojiChooserDropdown.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 33 | 38 | -------------------------------------------------------------------------------- /core/page.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | <%= htmlWebpackPlugin.options.title %> 11 | 12 | 13 | 14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /core/webpack.dev.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const merge = require('webpack-merge') 3 | const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin') 4 | const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer') 5 | const { styleLoaders } = require('./tools') 6 | const baseWebpack = require('./webpack.base') 7 | 8 | function genPlugins () { 9 | const plugins = [ 10 | new webpack.NoEmitOnErrorsPlugin(), 11 | new FriendlyErrorsPlugin() 12 | ] 13 | if (process.env.ANALYZER) { 14 | plugins.push(new BundleAnalyzerPlugin()) 15 | } 16 | return plugins 17 | } 18 | 19 | module.exports = (env) => { 20 | env.NODE_ENV = 'development' 21 | env.DEV = 'true' 22 | return merge(baseWebpack(env), { 23 | mode: 'development', 24 | watch: true, 25 | module: { rules: styleLoaders({ sourceMap: false }) }, 26 | devtool: '#cheap-module-eval-source-map', 27 | // devtool: '#cheap-module-source-map', 28 | plugins: genPlugins() 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /core/webpack.beta.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const merge = require('webpack-merge') 3 | const baseWebpack = require('./webpack.base') 4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 5 | const OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin') 6 | const ZipPlugin = require('zip-webpack-plugin') 7 | const { styleLoaders } = require('./tools') 8 | 9 | module.exports = (env) => { 10 | env.NODE_ENV = 'production' 11 | env.BETA = true 12 | return merge(baseWebpack(env), { 13 | mode: 'production', 14 | module: { rules: styleLoaders({ extract: true, sourceMap: true }) }, 15 | plugins: [ 16 | new OptimizeCSSPlugin({ cssProcessorOptions: { safe: true } }), 17 | new MiniCssExtractPlugin({ filename: 'css/[name].[contenthash].css' }), 18 | new webpack.HashedModuleIdsPlugin(), 19 | new ZipPlugin({ 20 | path: '../..', 21 | filename: (env.FIREFOX) ? 'beta-firefox.zip' : 'beta-chrome.zip' 22 | }) 23 | ] 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /core/webpack.prod.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const merge = require('webpack-merge') 3 | const baseWebpack = require('./webpack.base') 4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 5 | const OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin') 6 | const ZipPlugin = require('zip-webpack-plugin') 7 | const { styleLoaders } = require('./tools') 8 | 9 | module.exports = (env) => { 10 | env.NODE_ENV = 'production' 11 | return merge(baseWebpack(env), { 12 | mode: 'production', 13 | module: { rules: styleLoaders({ extract: true, sourceMap: true }) }, 14 | devtool: '#cheap-module-source-map', 15 | plugins: [ 16 | new OptimizeCSSPlugin({ cssProcessorOptions: { safe: true } }), 17 | new MiniCssExtractPlugin({ filename: 'css/[name].[contenthash].css' }), 18 | new webpack.HashedModuleIdsPlugin(), 19 | new ZipPlugin({ 20 | path: '../..', 21 | filename: (env.FIREFOX) ? 'release-firefox.zip' : 'release-chrome.zip' 22 | }) 23 | ] 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /src/tab/classes/Threads.js: -------------------------------------------------------------------------------- 1 | export default class Threads extends Array { 2 | /** 3 | * @constructor 4 | * @param {Array} threadsData threads with Array. 5 | */ 6 | constructor (threadsData) { 7 | super(...threadsData) 8 | } 9 | 10 | /** 11 | * Return users in this threads. 12 | * @return {Array} Users in this threads. 13 | */ 14 | get users () { 15 | return this.reduce((cur, thread) => { 16 | // Take out all user in this thread. 17 | const users = (thread.participants || []).map((participant) => participant.user) 18 | users.forEach((user) => { 19 | // If this user not in array "cur". Push it. 20 | if (!cur.find((participant) => participant.id === user.id)) { 21 | cur.push(thread.participants) 22 | } 23 | }) 24 | }, []) 25 | } 26 | 27 | /** 28 | * Get user by ID. 29 | * @param {String} id User's ID 30 | * @return {User|null} 31 | */ 32 | getUserById (id) { 33 | return this.users.find((user) => user.id === id) || null 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/tab/components/ThreadList/DetailTemplate/ColorChooserDropdown.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 34 | 44 | -------------------------------------------------------------------------------- /src/tab/components/ThreadList/OperationButton.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 56 | -------------------------------------------------------------------------------- /src/options/root.vue: -------------------------------------------------------------------------------- 1 | 21 | 45 | 47 | -------------------------------------------------------------------------------- /core/deploy.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const WebStore = require('chrome-webstore-upload') 3 | const env = require('./.env') 4 | let config = env.release // Use release env by default. 5 | 6 | let mode = 'release' 7 | if (env.beta) { // if "beta" env exist and use beta mode. 8 | const isBeta = !!process.argv.find((arg) => arg === '--beta') 9 | mode = (isBeta) ? 'beta' : 'release' 10 | config = env[mode] 11 | } 12 | 13 | const webStoreClient = WebStore({ 14 | extensionId: config.extensionId, 15 | clientId: config.clientId, 16 | clientSecret: config.clientSecret, 17 | refreshToken: config.refreshToken 18 | }) 19 | 20 | console.log('Start fetch token.') 21 | webStoreClient.fetchToken() 22 | .then(async (token) => { 23 | if (!config.extensionId) { // no extension id, publish first 24 | console.log('Start publish.') 25 | webStoreClient.publish('default', token) 26 | .then((res) => { 27 | webStoreClient.extensionId = res.item_id 28 | console.log('Your new extension id is ' + res.item_id) 29 | console.log('Please paste it into your .env') 30 | }) 31 | .catch((err) => console.error(err)) 32 | } 33 | 34 | const file = fs.createReadStream(`./${mode}-chrome.zip`) 35 | console.log('Start upload.') 36 | try { 37 | const res = await webStoreClient.uploadExisting(file, token) 38 | if (res.uploadState === 'FAILURE') return console.error('Upload failed.', res) 39 | console.log('Start publish.') 40 | await webStoreClient.publish('default', token) 41 | } catch (err) { 42 | console.error(err) 43 | } 44 | }) 45 | .catch((err) => console.error(err)) 46 | -------------------------------------------------------------------------------- /core/tools.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require('path') 2 | // const MiniCssExtractPlugin = require('mini-css-extract-plugin') 3 | const HtmlWebpackPlugin = require('html-webpack-plugin') 4 | 5 | exports.htmlPage = (title, filename, chunks, template) => new HtmlWebpackPlugin({ 6 | title, 7 | hash: true, 8 | cache: true, 9 | inject: 'body', 10 | filename: './pages/' + filename + '.html', 11 | template: template || resolve(__dirname, './page.ejs'), 12 | appMountId: 'app', 13 | chunks 14 | }) 15 | 16 | exports.cssLoaders = (options = {}) => { 17 | const loaders = {} 18 | const prePprocessors = { 19 | css: {}, 20 | postcss: {}, 21 | less: { loader: 'less' }, 22 | sass: { loader: 'sass', options: { indentedSyntax: true } }, 23 | scss: { loader: 'sass' }, 24 | stylus: { loader: 'stylus' }, 25 | styl: { loader: 'stylus' } 26 | } 27 | for (const key in prePprocessors) { 28 | const loader = [{ 29 | loader: 'css-loader' 30 | // options: { minimize: process.env.NODE_ENV === 'production' } 31 | }] 32 | if (prePprocessors[key].loader) { 33 | loader.push({ 34 | loader: prePprocessors[key].loader + '-loader', 35 | options: Object.assign({}, prePprocessors[key].options, { sourceMap: options.sourceMap }) 36 | }) 37 | } 38 | loaders[key] = ['vue-style-loader', ...loader] 39 | } 40 | return loaders 41 | } 42 | 43 | exports.styleLoaders = function (options) { 44 | const output = [] 45 | const loaders = exports.cssLoaders(options) 46 | for (const extension in loaders) { 47 | const loader = loaders[extension] 48 | output.push({ 49 | test: new RegExp('\\.' + extension + '$'), 50 | use: loader 51 | }) 52 | } 53 | return output 54 | } 55 | -------------------------------------------------------------------------------- /src/tab/README.md: -------------------------------------------------------------------------------- 1 | ### Thread Object & User Object 2 | 3 | We can't save all response data in memory(too big) so I defined a thread 4 | object to decrease used of memory. 5 | 6 | If need other information, you can add properties what you need. But please 7 | don't save "all message history". It's consume too many resourse. 8 | 9 | Attention: Thread.messages doesn't be trigger by vue instace. Coz if every 10 | message be trigger, will consume memory resources, not to mention there are 11 | tens of thousands of messages. In my case, it consumes twice as much memory 12 | usage. 13 | 14 | #### Thread Object: 15 | 16 | ```js 17 | { 18 | id: 'thread id', 19 | name: 'thread name', 20 | tooltip: 'other user\'s name', 21 | type: 'USER' || 'GROUP' || 'FANPAGE', 22 | messages: [{ // messages doesn't be trigger by vue instace. 23 | sender: User, // User Object 24 | timestamp: 'message timestamp', 25 | text: 'content, if it\'s a sticker or other media, can be null', 26 | sticker: 'sticker url if has sticker' 27 | // TODO: media, e.g. picture, video... 28 | }], 29 | participants: [{ 30 | user: User, // User Object 31 | messageCount: 'message count of this participant', 32 | characterCount: 'character count of this participant', 33 | // Is this user still in the thread? true or false. If somebody ever send 34 | // messages in thread, means he was a participant. And then inGroup is false. 35 | inGroup: true || false 36 | }] 37 | messageCount: 'total message count', 38 | characterCount: 'total character count' 39 | } 40 | ``` 41 | 42 | #### User Object: 43 | 44 | ```js 45 | { 46 | id: 'participant\'s id', 47 | name: 'participant\'s name', 48 | type: 'participant\'s type', 49 | url: 'participant\'s messenger url' 50 | } 51 | ``` 52 | -------------------------------------------------------------------------------- /src/tab/components/AboutPage.vue: -------------------------------------------------------------------------------- 1 | 23 | 34 | 35 | 55 | -------------------------------------------------------------------------------- /src/tab/components/ThreadList/NameTemplate/index.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 45 | 46 | 52 | 53 | 63 | -------------------------------------------------------------------------------- /plugins/GenerateLocaleJsonPlugin.js: -------------------------------------------------------------------------------- 1 | const { lstatSync, readdirSync, readFileSync } = require('fs') 2 | const { join, parse } = require('path') 3 | const Module = require('module') 4 | 5 | const isDirectory = (source) => lstatSync(source).isDirectory() 6 | const getDirectories = (source) => 7 | readdirSync(source).map(name => join(source, name)).filter(isDirectory) 8 | 9 | module.exports = class GenerateLocaleJsonPlugin { 10 | constructor ({ _locales }) { 11 | this.__localesPath = _locales 12 | this._locales = [] 13 | } 14 | 15 | compile (comp) { 16 | const dirsPath = getDirectories(this.__localesPath) 17 | dirsPath.forEach((dirPath) => { 18 | try { 19 | const localeName = parse(dirPath).base 20 | const filename = join(this.__localesPath, localeName, 'messages.js') 21 | const code = readFileSync(filename, 'utf8') 22 | const mod = new Module(filename) 23 | mod.filename = filename 24 | mod._compile(code, filename) 25 | mod.paths = Module._nodeModulePaths(filename) 26 | this._locales.push({ 27 | localeName, 28 | content: mod.exports, 29 | src: join(dirPath, 'messages.js') 30 | }) 31 | } catch (err) { 32 | console.error(err) 33 | } 34 | }) 35 | } 36 | 37 | generate (comp) { 38 | if (!this._locales.length) return comp 39 | 40 | for (const locale of this._locales) { 41 | comp.fileDependencies.add(locale.src) 42 | const source = JSON.stringify(locale.content) 43 | comp.assets[join('_locales', locale.localeName, 'messages.json')] = { 44 | source: () => source, 45 | size: () => source.length 46 | } 47 | } 48 | 49 | return comp 50 | } 51 | 52 | apply (compiler) { 53 | compiler.hooks.compile.tap('compile', (comp) => this.compile(comp)) 54 | compiler.hooks.emit.tap('emit', (comp) => this.generate(comp)) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/tab/lib/shareOnFb.js: -------------------------------------------------------------------------------- 1 | import { 2 | toQuerystring, 3 | uploadImage 4 | } from './util.js' 5 | import { fb } from '@/../core/.env.js' 6 | 7 | const __ = chrome.i18n.getMessage 8 | 9 | export default async function shareOnFb (canvas, jar) { 10 | /** @see https://developers.facebook.com/docs/sharing/best-practices/#images **/ 11 | const imageSize = { width: 1200, height: 630 } 12 | 13 | try { 14 | // output blob 15 | const blob = await new Promise((resolve, reject) => canvas.toBlob(resolve)) 16 | 17 | // upload image to fb 18 | const metadata = (await uploadImage(jar, blob)).payload.metadata[0] 19 | 20 | // construct fb sharing dialog url 21 | const channelUrlHash = toQuerystring({ 22 | cb: 'f2c3a60a05f73a4', 23 | domain: fb.domain, 24 | origin: fb.website, 25 | relation: 'opener' 26 | }) 27 | const channelUrl = 'http://staticxx.facebook.com/connect/xd_arbiter/r/Ms1VZf1Vg1J.js?version=42#' + channelUrlHash 28 | const next = `${channelUrl}&relation=opener&frame=f236fd3a78b853&result=%22xxRESULTTOKENxx%22` 29 | const qs = toQuerystring({ 30 | action_properties: { 31 | object: { 32 | 'og:url': fb.website, 33 | 'og:title': __('extName'), 34 | 'og:description': __('extDescription'), 35 | 'og:image': metadata.src, 36 | 'og:image:width': imageSize.width, 37 | 'og:image:height': imageSize.height, 38 | 'og:image:type': metadata.filetype 39 | } 40 | }, 41 | action_type: 'og.likes', // Coz "og.shares" cannot show bigger preview image, use "og.likes" instead of "og.shares". 42 | app_id: fb.id, 43 | channel_url: channelUrl, 44 | e2e: {}, 45 | locale: __('@@ui_locale'), 46 | mobile_iframe: false, 47 | next, 48 | sdk: 'joey', 49 | version: fb.version 50 | }) 51 | const url = `https://www.facebook.com/${fb.version}/dialog/share_open_graph?${qs}` 52 | 53 | // create fb dialog page 54 | chrome.tabs.create({ 55 | url 56 | }) 57 | } catch (err) { 58 | console.error(err) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/tab/components/ThreadList/Avatar.vue: -------------------------------------------------------------------------------- 1 | 26 | 44 | 93 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Counter of Messenger 2 | 3 | ## How to contribute. Importing and assembling 4 | 5 | If you have any questions, feel free to contact with me in English or Chinese. 6 | 7 | ### Before importation 8 | 9 | - Make sure you are using *Node.js 8*. 10 | - Install dependencies. 11 | 12 | ### Development 13 | 14 | You can develop on both browser at same time. 15 | ```sh 16 | npm run dev # Chrome 17 | npm run dev-firefox # Firefox 18 | ``` 19 | 20 | > Hint: Going to firefox's about:config and toggling `network.websocket.allowInsecureFromHTTPS`. 21 | > https:// to ws:// is disallowed by default on firefox. 22 | 23 | #### Stages 24 | 25 | | Stage | env | cmd | 26 | |---------|-------------|----------------------| 27 | | Release | production | `npm run build` | 28 | | Beta | production | `npm run build-beta` | 29 | | Dev | development | `npm run dev` | 30 | 31 | > If you want to emulate beta stage in development mode: `npm run dev -- --env.BETA` 32 | 33 | ### Submitting Pull Request 34 | 35 | > Please use meaningful commit messages. 36 | 37 | - Create a new Branch with the changes you made. 38 | - Submit your Pull Request with an explanation of what have you done and why. 39 | 40 | > I really appreciate your efforts on contributing to this project. 41 | 42 | ## Working with translations 43 | 44 | 1. Firstly, you have to fork the repository by clicking the **Fork** button. 45 | 1. Clone your own forked repository to your workstation. 46 | 1. Create and switch Branch by typing `git checkout -b ` where `` is the name of the Branch you want to work with. We recommend you to name it into the language you want to translate in. 47 | 1. Create a new directory named as a 2 letter ISO code of the language. For example `es` for Spanish, `ja` for Japanese. 48 | 1. Copy `src/_locales/en/messages.js` into the directory you have created. 49 | 1. Translate 50 | 1. Once you finished translating, add new files to the Git index using `git add src/_locales/??/messages.js` command and commit the changes using `git commit -m ''`, where `` is a short description of changes you made. 51 | 1. Push your local changes into your forked repository by typing `git push origin `. 52 | 1. Finally, create a Pull Request from your Branch to our main Branch *master*. 53 | -------------------------------------------------------------------------------- /src/manifest.js: -------------------------------------------------------------------------------- 1 | const config = require('../core/.env') 2 | 3 | const manifest = { 4 | name: '__MSG_extName__', 5 | version: '0.2.6', 6 | version_name: (process.env.CHROME) ? '0.2.6' : undefined, 7 | description: '__MSG_extDescription__', 8 | author: 'ALiangLiang', 9 | manifest_version: 2, 10 | icons: { 11 | 16: 'icons/16.png', 12 | 19: 'icons/19.png', 13 | 38: 'icons/38.png', 14 | 64: 'icons/64.png', 15 | 128: 'icons/128.png' 16 | }, 17 | browser_action: { 18 | default_icon: { 16: 'icons/16.png' }, 19 | default_title: '__MSG_extName__' 20 | }, 21 | permissions: [ 22 | 'tabs', 23 | 'identity', 24 | 'downloads', 25 | 'webRequest', 26 | 'webRequestBlocking', 27 | '*://*.facebook.com/*', 28 | 'https://www.googleapis.com/', 29 | 'https://www-googleapis-staging.sandbox.google.com/' 30 | ], 31 | background: { 32 | persistent: true, 33 | page: 'pages/background.html' 34 | }, 35 | content_scripts: [{ 36 | js: [ 37 | 'js/content.js' 38 | ], 39 | matches: ['*://*.facebook.com/'], 40 | run_at: 'document_end' 41 | }], 42 | default_locale: 'en', 43 | content_security_policy: `script-src 'self' ${(process.env.NODE_ENV === 'development') ? '\'unsafe-eval\'' : ''} https://connect.facebook.net https://www.google-analytics.com https://www.google.com https://checkout.google.com; object-src 'self'` 44 | } 45 | 46 | const optionPage = 'pages/options.html' 47 | const stage = (process.env.ALPHA) ? 'alpha' : ((process.env.BETA) ? 'beta' : ((process.env.DEV) ? 'dev' : 'release')) 48 | console.info('Stage:', stage) 49 | const browser = (process.env.FIREFOX) ? 'Firefox' : 'Chrome' 50 | console.info('Browser:', browser) 51 | if (!process.env.FIREFOX) { 52 | Object.assign(manifest, { 53 | options_page: optionPage, 54 | oauth2: { 55 | client_id: config[stage].clientId, 56 | scopes: [ 57 | 'https://www.googleapis.com/auth/plus.login', 58 | 'https://www.googleapis.com/auth/chromewebstore', 59 | 'https://www.googleapis.com/auth/chromewebstore.readonly' 60 | ] 61 | }, 62 | key: config[stage].key 63 | }) 64 | } else { 65 | delete manifest.background.persistent // Firefox not support background.persistent 66 | Object.assign(manifest, { 67 | applications: { 68 | gecko: { 69 | id: 'counter-for-messenger@aliangliang.top', 70 | strict_min_version: '56.0' 71 | } 72 | }, 73 | options_ui: { 74 | page: optionPage 75 | } 76 | }) 77 | } 78 | 79 | module.exports = manifest 80 | -------------------------------------------------------------------------------- /src/backend/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 用來做偽造 origin header,模擬 www.facebook.com 網域環境。 3 | * Use to replace header "Origin". Simulate environment of "www.facebook.com". 4 | */ 5 | function handleRequestHeaders (details) { 6 | // exit on no origin found or request not send from this extension. 7 | if (details.initiator !== document.location.origin) return 8 | 9 | // Origin 10 | const origin = details.requestHeaders.find((header) => header.name.toUpperCase() === 'ORIGIN') 11 | if (origin) { 12 | origin.value = 'https://www.facebook.com' 13 | } else { 14 | details.requestHeaders.push({ 15 | name: 'Origin', 16 | value: 'https://www.facebook.com' 17 | }) 18 | } 19 | // Sec-Fetch-Site 20 | const secFetchSite = details.requestHeaders.find((header) => header.name.toUpperCase() === 'SEC-FETCH-SITE') 21 | if (secFetchSite) { 22 | secFetchSite.value = 'same-origin' 23 | } else { 24 | details.requestHeaders.push({ 25 | name: 'Sec-Fetch-Site', 26 | value: 'same-origin' 27 | }) 28 | } 29 | // Referer 30 | details.requestHeaders.push({ 31 | name: 'Referer', 32 | value: 'https://www.facebook.com/' 33 | }) 34 | // Host 35 | details.requestHeaders.push({ 36 | name: 'Host', 37 | value: 'www.facebook.com' 38 | }) 39 | 40 | // return back. continuely send out this request 41 | return { requestHeaders: details.requestHeaders } 42 | } 43 | 44 | const requestFilter = { urls: ['*://*.facebook.com/*'], types: ['xmlhttprequest'] } 45 | 46 | chrome.webRequest.onBeforeSendHeaders.addListener(handleRequestHeaders, 47 | requestFilter, ['blocking', 'requestHeaders', 'extraHeaders']) 48 | 49 | /** 50 | * 當按下 browserAction 按鈕實,觸發開啟 Counter 頁面的事件。 51 | * When client click browserAction button, trigger event of launch counter. 52 | */ 53 | chrome.browserAction.onClicked.addListener(async () => { 54 | // create app page 55 | chrome.tabs.create({ url: '/pages/app.html' }) 56 | }) 57 | 58 | // directly launch app on installed. 59 | chrome.runtime.onInstalled.addListener(({ reason }) => { 60 | if (reason === 'install') { 61 | // create app page 62 | chrome.tabs.create({ url: '/pages/app.html' }) 63 | } 64 | }) 65 | 66 | // Mark beta version by badge. 67 | const isRelease = !process.env.DEV && !process.env.ALPHA && !process.env.BETA 68 | if (!isRelease) { 69 | const badgeText = (process.env.ALPHA) ? 'Alph' : ((process.env.BETA) ? 'Beta' : 'Dev') 70 | const badgeColor = (process.env.ALPHA) ? [0, 0, 255, 255] : ((process.env.BETA) ? [255, 0, 0, 255] : [239, 165, 15, 255]) 71 | chrome.browserAction.setBadgeText({ text: badgeText }) 72 | chrome.browserAction.setBadgeBackgroundColor({ color: badgeColor }) 73 | } 74 | -------------------------------------------------------------------------------- /src/tab/components/SharingDialog.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 83 | 84 | 90 | 91 | 105 | -------------------------------------------------------------------------------- /src/tab/components/ThreadList/NameTemplate/ThreadName.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 86 | 87 | 92 | 93 | 108 | -------------------------------------------------------------------------------- /src/tab/components/ThreadList/DetailTemplate/MuteUntil.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 87 | -------------------------------------------------------------------------------- /src/ext/Indexeddb.js: -------------------------------------------------------------------------------- 1 | const __VERSION__ = 2 2 | 3 | function promisifyRequestResult (request) { 4 | return new Promise(function (resolve, reject) { 5 | request.onerror = reject 6 | request.onsuccess = (event) => resolve(request.result) 7 | }) 8 | } 9 | 10 | export default class Indexeddb { 11 | constructor (selfId) { 12 | this._indexedDB = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB 13 | if (!this._indexedDB) { 14 | window.alert('Your browser doesn\'t support a stable version of IndexedDB. Such and such feature will not be available.') 15 | } 16 | this._loaded = false 17 | this._dbName = selfId 18 | const request = this._indexedDB.open(this._dbName, __VERSION__) 19 | request.onerror = function (err) { console.error(err) } 20 | request.onsuccess = (event) => { 21 | this._db = event.currentTarget.result 22 | if (this._onload) { 23 | this._loaded = true 24 | this._onload() 25 | } 26 | } 27 | request.onupgradeneeded = function (event) { 28 | if (event.oldVersion < 1) { 29 | // do initial schema creation 30 | event.currentTarget.result.createObjectStore('Threads', { 31 | keyPath: 'id' 32 | }) 33 | } 34 | if (event.oldVersion < 2) { 35 | // rebuild db 36 | event.currentTarget.result.deleteObjectStore('Threads') 37 | event.currentTarget.result.createObjectStore('Threads', { 38 | keyPath: 'id' 39 | }) 40 | } 41 | } 42 | } 43 | 44 | set onload (cb) { 45 | this._onload = cb 46 | if (this._loaded) cb() 47 | } 48 | 49 | get onload () { 50 | return this._onload 51 | } 52 | 53 | get (senderID) { 54 | const request = this._db.transaction('Threads') 55 | .objectStore('Threads') 56 | .get(senderID) 57 | return promisifyRequestResult(request) 58 | } 59 | 60 | getAll () { 61 | const request = this._db.transaction('Threads') 62 | .objectStore('Threads') 63 | .getAll() 64 | return promisifyRequestResult(request) 65 | } 66 | 67 | add (values) { 68 | values = (values instanceof Array) ? values : [values] 69 | const objectStore = this._db.transaction('Threads', 'readwrite').objectStore('Threads') 70 | return Promise.all(values.map((val) => promisifyRequestResult(objectStore.add(val)))) 71 | } 72 | 73 | put (values) { 74 | values = (values instanceof Array) ? values : [values] 75 | const objectStore = this._db.transaction('Threads', 'readwrite').objectStore('Threads') 76 | return Promise.all(values.map((val) => promisifyRequestResult(objectStore.put(val)))) 77 | } 78 | 79 | remove (senderID) { 80 | const request = this._db.transaction('Threads', 'readwrite') 81 | .objectStore('Threads') 82 | .delete(senderID) 83 | return promisifyRequestResult(request) 84 | } 85 | 86 | clear () { 87 | const request = this._db.transaction('Threads', 'readwrite') 88 | .objectStore('Threads') 89 | .clear() 90 | return promisifyRequestResult(request) 91 | } 92 | 93 | destroy () { 94 | const request = this._indexedDB.deleteDatabase(this._dbName) 95 | return promisifyRequestResult(request) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/tab/components/ThreadList/DetailTemplate/Chooser.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 78 | 79 | 113 | -------------------------------------------------------------------------------- /src/tab/components/download/Message.vue: -------------------------------------------------------------------------------- 1 | 47 | 88 | -------------------------------------------------------------------------------- /README-zh-TW.md: -------------------------------------------------------------------------------- 1 | # ![Logo](.github/assets/icon.png) Messenger 計數器 [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.me/ALiangLiang/5) 2 | 3 | Chome 4 | [ ](https://chrome.google.com/webstore/detail/ldlagicdigidgnhniajpmoddkoakdoca) 5 | [![Chrome Web Store Users](https://img.shields.io/chrome-web-store/users/ldlagicdigidgnhniajpmoddkoakdoca.svg?label=Users)](https://chrome.google.com/webstore/detail/ldlagicdigidgnhniajpmoddkoakdoca) 6 | [![Chrome Web Store](https://img.shields.io/chrome-web-store/rating/ldlagicdigidgnhniajpmoddkoakdoca.svg?label=Rating&colorB=dfb317)](https://chrome.google.com/webstore/detail/ldlagicdigidgnhniajpmoddkoakdoca) 7 | [![Chrome Web Store BETA](https://img.shields.io/chrome-web-store/v/flkejcheidpcclcdokndihmnlejfabil.svg?label=Beta)](https://chrome.google.com/webstore/detail/flkejcheidpcclcdokndihmnlejfabil) 8 | 9 | Firefox 10 | [ ](https://addons.mozilla.org/firefox/addon/counter-for-messenger/) 11 | [![Mozilla Add-on](https://img.shields.io/amo/users/counter-for-messenger.svg)](https://addons.mozilla.org/firefox/addon/counter-for-messenger/) 12 | [![Mozilla Add-on](https://img.shields.io/amo/rating/counter-for-messenger.svg)](https://addons.mozilla.org/firefox/addon/counter-for-messenger/) 13 | 14 | 15 | 16 | 🌎 | [English](README.md) | 正體中文 (Traditional Chinese) 17 | ------------- | ------------- | ------------- 18 | 19 | 統計你在 Messenger 中,與朋友們的訊息數量,並且排名!! 20 | 快來看看你與哪個朋友最麻吉唄! 21 | 想不想知道與好朋友聊了幾句?想回味與她聊天的點點滴滴嗎? 22 | 這時候這碗糕就派上用場了!!還不快安裝!? 23 | 24 |

25 | 26 | 從 Chrome Web Store 安裝 27 | 28 |

29 | 30 |

31 | 示意圖 32 |

33 | 34 | ## 🔥 特色 35 | 36 | - 💬 **統計** 37 | - 多少聊天室 38 | - 聊天室的訊息量 39 | - 聊天室的文字量 40 | - 📊 將所有聊天室**排名** 41 | - 💾 **下載**聊天紀錄 42 | - ⚙️ **設定**你的聊天室 (表情符號、顏色、名稱等...) 43 | 44 | ## 📄 使用 45 | 安裝完畢後,點擊 Chrome 右上角的 LOGO 圖示 Logo,如果找不到,點擊右上角的「三顆點」按鈕,就可以找到了。 46 | ![點logo](.github/assets/click_icon1.png) 47 | ![點隱藏的logo](.github/assets/click_icon2.png) 48 | 49 | ## 🔧 貢獻 50 | 51 | 如果你喜歡這個套件的話,又或是想要新的功能、修復 bug,歡迎 [PR](https://github.com/ALiangLiang/Counter-for-Messenger/compare) 或是[建立 issue](https://github.com/ALiangLiang/Counter-for-Messenger/issues/new),閱讀[貢獻守則](.github/CONTRIBUTING.md)來瞭解更多資訊。 52 | 53 | 如果你有一定的技術,可以使用[公開測試版本](https://chrome.google.com/webstore/detail/flkejcheidpcclcdokndihmnlejfabil) 54 | ,並且幫助我們在釋出正式版前找出錯誤。 55 | 56 | ### 🌎 翻譯 57 | 58 | 我們歡迎大家增加各種語言的支援,協助各位的同胞使用這個擴充套件。可以參考 [CONTRIBUTING - Working with translations](.github/CONTRIBUTING.md#working-with-translations)。 59 | 60 | ## 📣 聲明 61 | 62 | - **這是非官方專案** 63 | - **不會收集使用者從 Messenger 來的數據** 64 | 65 | ## 👨‍💻 開發 66 | 67 | ``` 68 | yarn # 安裝相依套件 69 | # Chrome 70 | npm run dev # 開發 71 | npm run build-chrome # 生產 72 | # Firefox 73 | npm run dev-firefox # 開發 74 | npm run build-firefox # 生產 75 | ``` 76 | 77 | ## ☕ 捐獻 78 | 79 | 如果你喜歡這個專案,歡迎抖內(donate),讓我買杯咖啡好熬肝 XDDD 80 | 81 | Buy Me A Coffee 82 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "counter-for-messenger", 3 | "version": "0.2.6", 4 | "description": "Count and rank your friends by analysing your Messenger! Check out and download the messaging history of you and your best friends!", 5 | "author": "ALiangLiang ", 6 | "license": "MIT", 7 | "private": false, 8 | "dependencies": { 9 | "chart.js": "^2.7.1", 10 | "color-hash": "^1.0.3", 11 | "element-ui": "^2.11.1", 12 | "jszip": "^3.1.5", 13 | "lodash": "^4.17.4", 14 | "promise-queue": "^2.2.5", 15 | "vue": "^2.6.10", 16 | "vue-analytics": "^5.8.0", 17 | "vue-awesome": "2", 18 | "vue-chartjs": "^3.1.0", 19 | "vue-data-tables": "^3.1.4", 20 | "vue-meta": "^2.2.1", 21 | "vue-pouch": "^0.0.23", 22 | "vue-router": "^3.0.1", 23 | "vue-social-sharing": "^2.3.3", 24 | "vuex": "^3.0.0" 25 | }, 26 | "scripts": { 27 | "lint": "eslint --ext .js,.vue src", 28 | "dev": "webpack --config ./core/webpack.dev.js --env.CHROME", 29 | "dev-firefox": "webpack --config ./core/webpack.dev.js --env.FIREFOX", 30 | "build": "webpack --config ./core/webpack.prod.js -p --progress --colors --env.CHROME", 31 | "build-firefox": "webpack --config ./core/webpack.prod.js -p --progress --colors --env.FIREFOX", 32 | "beta-deploy": "webpack --config ./core/webpack.beta.js -p --progress --colors --env.BETA && node core/deploy.js --beta", 33 | "deploy": "webpack --config ./core/webpack.prod.js -p --progress --colors --env.CHROME && node core/deploy.js" 34 | }, 35 | "devDependencies": { 36 | "@babel/core": "^7.5.5", 37 | "@babel/plugin-transform-runtime": "^7.5.5", 38 | "@babel/preset-env": "^7.5.5", 39 | "@babel/preset-stage-2": "^7.0.0", 40 | "@babel/runtime": "^7.5.5", 41 | "archiver": "^3.1.1", 42 | "babel-eslint": "^10.0.3", 43 | "babel-helper-vue-jsx-merge-props": "^2.0.3", 44 | "babel-loader": "^8.0.6", 45 | "babel-plugin-syntax-jsx": "^6.18.0", 46 | "babel-plugin-transform-vue-jsx": "^3.5.1", 47 | "babel-register": "^6.26.0", 48 | "buble": "^0.19.8", 49 | "buble-loader": "^0.5.1", 50 | "chrome-webstore-upload": "rocketrip/chrome-webstore-upload.git#962e66e89291e4b0bdd24dcf2183a7f07e9e074a", 51 | "clean-webpack-plugin": "^3.0.0", 52 | "copy-webpack-plugin": "^5.0.4", 53 | "cross-env": "^5.1.0", 54 | "css-loader": "^3.2.0", 55 | "enhanced-resolve": "^4.1.0", 56 | "eslint": "^6.2.2", 57 | "eslint-config-standard": "^14.1.0", 58 | "eslint-friendly-formatter": "^4.0.1", 59 | "eslint-loader": "^3.0.0", 60 | "eslint-plugin-html": "^6.0.0", 61 | "eslint-plugin-import": "^2.8.0", 62 | "eslint-plugin-node": "^9.1.0", 63 | "eslint-plugin-promise": "^4.2.1", 64 | "eslint-plugin-standard": "^4.0.1", 65 | "eslint-plugin-vue-libs": "^4.0.0", 66 | "file-loader": "^4.2.0", 67 | "friendly-errors-webpack-plugin": "^1.6.1", 68 | "html-loader": "^0.5.5", 69 | "html-webpack-plugin": "^3.2.0", 70 | "mini-css-extract-plugin": "^0.8.0", 71 | "node-sass": "^4.5.3", 72 | "nodemon": "^1.12.1", 73 | "optimize-css-assets-webpack-plugin": "^5.0.3", 74 | "pug": "^2.0.0-rc.4", 75 | "pug-loader": "^2.3.0", 76 | "sass-loader": "^7.3.1", 77 | "url-loader": "^2.1.0", 78 | "vue-eslint-parser": "^6.0.4", 79 | "vue-loader": "^15.7.1", 80 | "vue-style-loader": "^4.1.2", 81 | "vue-template-compiler": "^2.5.2", 82 | "wcer": "^1.0.2", 83 | "webpack": "^4.39.3", 84 | "webpack-bundle-analyzer": "^3.4.1", 85 | "webpack-cli": "^3.3.7", 86 | "webpack-dev-server": "^3.8.0", 87 | "webpack-merge": "^4.1.0", 88 | "zip-webpack-plugin": "^3.0.0" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/tab/lib/downloadMessages.js: -------------------------------------------------------------------------------- 1 | /// ////////////////////////////////////////////////////// 2 | // Let client backup(download) their messages history. // 3 | // Dump and wrapper to a HTML page // 4 | /// ////////////////////////////////////////////////////// 5 | // TODO: This function maybe broken when too much messages. Coz device resource leak. 6 | import { Message } from 'element-ui' 7 | import Vue from 'vue' 8 | import JSZip from 'jszip' 9 | import _chunk from 'lodash/chunk' 10 | import HtmlComponenet from '../components/download/Html.vue' 11 | const __ = chrome.i18n.getMessage 12 | 13 | const _errorHandler = () => { 14 | return Message({ 15 | type: 'error', 16 | dangerouslyUseHTMLString: true, 17 | message: `

Oops, cannot download messages. 18 | Please contact developer.

` 19 | }) 20 | } 21 | 22 | export default async function downloadMessages (info, selfId) { 23 | if (!selfId) { 24 | return _errorHandler() 25 | } 26 | const threads = (info instanceof Array) ? info : [info] 27 | const zip = new JSZip() 28 | const folderName = __('extName') 29 | const padLeft = (str, len) => String(str).padStart(len, '0') 30 | const date = new Date() 31 | const time = `${date.getFullYear()}${padLeft(date.getMonth() + 1, 2)}${padLeft(date.getDate(), 2)}` 32 | threads.forEach((thread) => { 33 | const { messages, participants } = thread 34 | const title = `${folderName} - ${thread.threadName} - ${time}` 35 | return _chunk(messages, 10000) // split every 10000 messages into chunks 36 | .forEach((messageChunk, i) => { 37 | const html = new Vue({ 38 | components: { HtmlElement: HtmlComponenet }, 39 | render (h) { 40 | return ( 41 | 46 | ) 47 | } 48 | }).$mount().$el 49 | 50 | // Create a empty document which is independant. Used to avoid load resource 51 | // like image, audio, video... 52 | const doc = document.implementation.createDocument(null, '', null) 53 | 54 | // initial a html element in document 55 | doc.appendChild(document.createElement('html')) 56 | 57 | // extract html element from document. 58 | const docHtml = doc.lastElementChild 59 | 60 | // append rendered head and body to independant document. 61 | Array.from(html.children).forEach((child) => docHtml.appendChild(child)) 62 | 63 | // html -> string -> blob 64 | const pageBlob = new Blob([ 65 | '', 66 | docHtml.outerHTML 67 | ], { type: 'text/html' }) 68 | 69 | // put file blob into zip 70 | const filename = `${folderName} - ${thread.threadName} - ${time} - ${i + 1}.html`.replace('(/|\\)', '') 71 | return zip.file(filename, pageBlob) 72 | }) 73 | }) 74 | 75 | try { 76 | // fetch assets into blob 77 | const assetBlob = await (await fetch('/assets/download.css')).blob() 78 | 79 | // also put into zip 80 | zip.file('download.css', assetBlob) 81 | 82 | // generate zip blob 83 | const zipBlob = await zip.generateAsync({ 84 | type: 'blob', 85 | compression: 'DEFLATE' 86 | }) 87 | 88 | // start download 89 | const filename = `${folderName} - ${time}.zip` 90 | chrome.downloads.download({ 91 | filename, 92 | url: URL.createObjectURL(zipBlob) 93 | }) 94 | } catch (err) { 95 | console.error(err) 96 | return _errorHandler() 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /static/assets/download.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 1.2em; 3 | background: linear-gradient(45deg,rgba(170, 224, 255, 0.17),rgba(120, 255, 120, 0.44),rgba(110, 255, 85, 0.4),rgba(247, 255, 130, 0.6)),linear-gradient(-45deg,#DAF1FF,#F3D8B6,rgba(123, 182, 255, 0.32),#FCFFD2); 4 | background: linear-gradient(45deg,rgba(211, 239, 255, 0.42),rgba(182, 243, 182, 0.44),rgba(143, 255, 123, 0.4),rgba(252, 255, 210, 0.48)),linear-gradient(-45deg,#DAF1FF,#F3D8B6,rgba(255, 123, 214, 0.17),#FCFFD2); 5 | background-attachment: fixed; 6 | font-family: Helvetica Neue, Segoe UI, Helvetica, Arial, "微軟正黑體", sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | } 9 | .container { 10 | width: 40%; 11 | min-width: 450px; 12 | max-width: 800px; 13 | margin: auto; 14 | background: rgba(255, 255, 255, 0.48); 15 | overflow: auto; 16 | padding: 1.0em 2em 1.6em 2em; 17 | } 18 | .clearfix:after { 19 | clear: both; 20 | content: "."; 21 | display: block; 22 | font-size: 0; 23 | height: 0; 24 | line-height: 0; 25 | visibility: hidden; 26 | } 27 | .title { 28 | text-align:center; 29 | color: #58636B; 30 | font-size:15px; 31 | padding-bottom: 13px; 32 | border-bottom: 1px solid rgba(0, 0, 0, .10); 33 | margin-bottom: 30px; 34 | } 35 | .avatar { 36 | border-radius: 50%; 37 | float: left; 38 | position: absolute; 39 | display: flex; 40 | } 41 | .outer { 42 | background: #FFFCDD; 43 | word-break: break-all; 44 | max-width: 70%; 45 | background: #FFFBD4; 46 | padding: 6px 12px; 47 | border-radius: 1.3em; 48 | margin: 11px 11px 25px 11px; 49 | clear: both; 50 | box-shadow: 0 1px 3px -1px rgba(0, 0, 0, 0.09); 51 | position: relative; 52 | } 53 | .message-text { 54 | font-size: 17px; 55 | line-height: 1.28; 56 | white-space: pre-wrap; 57 | word-wrap: break-word; 58 | } 59 | .outer.left { 60 | background: #f1f0f0; 61 | float: left; 62 | margin-left: 36px; 63 | color: black; 64 | } 65 | .outer.left:hover { 66 | background-color: #e5e4e4; 67 | } 68 | .outer.right { 69 | float: right; 70 | background: #0084ff; 71 | color: white; 72 | } 73 | .outer.right:hover { 74 | background-color: #0077e5; 75 | } 76 | .outer > div:before { 77 | content: attr(title); 78 | position: absolute; 79 | top: -1.2em; 80 | left: 0.7em; 81 | font-size: 0.8em; 82 | color: #5C6D86; 83 | display: block; 84 | width: 15em; 85 | } 86 | .outer > div:after { 87 | content: attr(time); 88 | position: absolute; 89 | bottom: -1.5em; 90 | left: 1em; 91 | font-size: 0.8em; 92 | color: #5C6D86; 93 | display: block; 94 | width: 15em; 95 | opacity: 0; 96 | transition: 0.2s all; 97 | transform: translateY(-3px); 98 | visibility: hidden; 99 | } 100 | .outer.right > div:after{ 101 | left: initial; 102 | right: 1em; 103 | text-align: right; 104 | } 105 | .outer:hover > div:after { 106 | opacity: 1; 107 | transform: translateY(0); 108 | visibility: visible; 109 | } 110 | .sticker-img { 111 | max-width: 120px; 112 | max-height: 120px; 113 | } 114 | .image-img { 115 | max-width: 100%; 116 | } 117 | .event-outer { 118 | clear: both; 119 | text-align: center; 120 | } 121 | .event-snippet { 122 | font-size: 12px; 123 | color: rgba(0, 0, 0, .40); 124 | } 125 | .reaction { 126 | position: absolute; 127 | right: 0; 128 | padding: 0 2px; 129 | align-items: center; 130 | background: #fff; 131 | align-items: center; 132 | background: #fff; 133 | border-radius: 10px; 134 | bottom: -15px; 135 | box-shadow: 0 2px 4px rgba(0, 0, 0, .15); 136 | color: #626262; 137 | display: flex; 138 | justify-content: center; 139 | font-size: 15px; 140 | } 141 | .reactionEmoji { 142 | 143 | } 144 | .reactionNum { 145 | font-size: 13px; 146 | font-weight: 600; 147 | margin: 0 -3px; 148 | padding: 4px; 149 | color: #0084ff; 150 | } 151 | @media screen and (max-width: 569px) { 152 | .container{ 153 | min-width: initial; 154 | width: 85%; 155 | } 156 | } 157 | #define { 158 | text-align:center; 159 | } 160 | -------------------------------------------------------------------------------- /src/tab/components/ThreadList/DetailTemplate/index.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 97 | 98 | 112 | -------------------------------------------------------------------------------- /src/tab/classes/Thread.js: -------------------------------------------------------------------------------- 1 | import fetchThreadDetail from '../lib/fetchThreadDetail.js' 2 | import fetchThread from './../lib/fetchThread.js' 3 | import User from './User.js' 4 | import _set from 'lodash/set' 5 | import _get from 'lodash/get' 6 | const __ = chrome.i18n.getMessage 7 | 8 | export default class Thread { 9 | constructor (data) { 10 | Object.assign(this, data) 11 | this.isLoading = false 12 | } 13 | 14 | /** 15 | * Get participant by ID. 16 | * @param {String} id User's ID 17 | * @return {User|null} 18 | */ 19 | getParticipantById (id) { 20 | return (this.participants && this.participants.length) 21 | ? this.participants.find((participant) => _get(participant, 'user.id') === id) || null 22 | : null 23 | } 24 | 25 | analyzeMessages (messages = this.messages) { 26 | if (!messages) throw new Error('Need messages.') 27 | 28 | let characterSum = 0 29 | // Statistic messages with every participants. And count character. 30 | const participantsStats = {} 31 | messages.forEach((message) => { 32 | const textLength = (message.body) ? message.body.length : 0 33 | characterSum += textLength 34 | const participantStats = participantsStats[message.senderID] 35 | _set(participantsStats, `${message.senderID}.messageCount`, 36 | _get(participantStats, 'messageCount', 0) + 1) 37 | _set(participantsStats, `${message.senderID}.characterCount`, 38 | _get(participantStats, 'characterCount', 0) + textLength) 39 | }) 40 | this.characterCount = characterSum 41 | 42 | // Set statistic results on Thread Object. 43 | // Don't let vue instance trigger "messages". Will cause memory leak. 44 | Object.keys(participantsStats) 45 | .forEach((participantId) => { 46 | const participantStats = participantsStats[participantId] 47 | let messageSender = this.getParticipantById(participantId) 48 | if (messageSender) { 49 | messageSender.messageCount = participantStats.messageCount 50 | messageSender.characterCount = participantStats.characterCount 51 | } else { 52 | messageSender = { 53 | user: new User({ 54 | id: participantId, 55 | name: `<${__('unknown')}>`, 56 | type: null, 57 | url: null 58 | }), 59 | messageCount: participantStats.messageCount, 60 | characterCount: participantStats.characterCount, 61 | inGroup: false 62 | } 63 | this.participants.push(messageSender) 64 | } 65 | }) 66 | } 67 | 68 | static culCharacterCount (messages) { 69 | return messages.reduce((cur, message) => 70 | ((message.body) ? message.body.length : 0) + cur, 0) 71 | } 72 | 73 | async reload (jar) { 74 | const newThreadData = await fetchThread(jar, this.id) 75 | // This API cannot load detail of participant information like name, nickname... 76 | // So don't overwrite original participants data. 77 | delete newThreadData.participants 78 | Object.assign(this, newThreadData) 79 | return this 80 | } 81 | 82 | async loadDetail (ctx, $set) { 83 | this.isLoading = true 84 | const cachedThread = await ctx.db.get(this.id) 85 | let messageLimit 86 | if (cachedThread) { 87 | const cachedThreadMessagesLength = _get(cachedThread, 'messages.length') 88 | if (cachedThreadMessagesLength !== undefined) { 89 | if (cachedThreadMessagesLength === this.messageCount) { // No need to update cache. 90 | this.isLoading = false 91 | return 92 | } 93 | if (!this.messages) { 94 | messageLimit = this.messageCount - cachedThreadMessagesLength 95 | } 96 | } 97 | } 98 | 99 | if (!this.messages) { 100 | const result = await fetchThreadDetail({ 101 | jar: ctx.jar, thread: this, $set: $set, messageLimit 102 | }) 103 | this.messages = (_get(cachedThread, 'messages') || []).concat(result) 104 | this.characterCount = Thread.culCharacterCount(this.messages) 105 | this.analyzeMessages() 106 | ctx.db.put({ id: this.id, messages: this.messages }) 107 | this.needUpdate = false 108 | } 109 | this.isLoading = false 110 | return this 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ⚠ This project has been discontinued for maintenance. If you are interested in this, you can refer the [spiritual successor](https://github.com/Kubis10/CounterForMessenger). 2 | 3 | # ![Logo](.github/assets/icon.png) Counter for Messenger [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.me/ALiangLiang/5) 4 | 5 | Chrome 6 | 7 | [](https://chrome.google.com/webstore/detail/ldlagicdigidgnhniajpmoddkoakdoca) 8 | [![Chrome Web Store Users](https://img.shields.io/chrome-web-store/users/ldlagicdigidgnhniajpmoddkoakdoca.svg?label=Users)](https://chrome.google.com/webstore/detail/ldlagicdigidgnhniajpmoddkoakdoca) 9 | [![Chrome Web Store](https://img.shields.io/chrome-web-store/rating/ldlagicdigidgnhniajpmoddkoakdoca.svg?label=Rating&colorB=dfb317)](https://chrome.google.com/webstore/detail/ldlagicdigidgnhniajpmoddkoakdoca) 10 | [![Chrome Web Store BETA](https://img.shields.io/chrome-web-store/v/flkejcheidpcclcdokndihmnlejfabil.svg?label=Beta)](https://chrome.google.com/webstore/detail/flkejcheidpcclcdokndihmnlejfabil) 11 | 12 | Firefox 13 | 14 | [](https://addons.mozilla.org/firefox/addon/counter-for-messenger/) 15 | [![Mozilla Add-on](https://img.shields.io/amo/users/counter-for-messenger.svg)](https://addons.mozilla.org/firefox/addon/counter-for-messenger/) 16 | [![Mozilla Add-on](https://img.shields.io/amo/rating/counter-for-messenger.svg)](https://addons.mozilla.org/firefox/addon/counter-for-messenger/) 17 | 18 | 🌎 | English | [正體中文 (Traditional Chinese)](README-zh-TW.md) 19 | --- | ------ | ------------- 20 | 21 | Count and rank your friends or lover by analysis you Messenger!! 22 | Take a look what you and your best friend chat!! 23 | 24 |

25 | 26 | Install from Chrome Web Store 27 | 28 |

29 | 30 |

31 | DEMO 32 |

33 | 34 | ## 🔥 Features 35 | 36 | - 💬 **Count** 37 | - Threads (Chat rooms) 38 | - Messages in thread 39 | - Characters in thread 40 | - 📊 **Rank** all your threads on chart. 41 | - 💾 **Backup**(Download) your messages. 42 | - ⚙️ **Customize** your threads. (color, emoji, name...) 43 | 44 | ## 📄 Instructions 45 | 46 | After installed, click the logo Logo on the top right of Chrome browser. 47 | If no any icon there, click "three dot" button and you can found the logo. 48 | ![click logo](.github/assets/click_icon1.png) 49 | ![click hidden logo](.github/assets/click_icon2.png) 50 | 51 | ## 🔧 Contributing 52 | 53 | Like Counter of Messenger? Want new feature or bug fixes? 54 | Please contribute to the project either by [_creating a PR_](https://github.com/ALiangLiang/Counter-for-Messenger/compare) or [_submitting an issue_](https://github.com/ALiangLiang/Counter-for-Messenger/issues/new) on GitHub. 55 | Read the [contribution guide](.github/CONTRIBUTING.md) for more detailed information. 56 | 57 | If you're technically-savvy, you can use our [beta version](https://chrome.google.com/webstore/detail/flkejcheidpcclcdokndihmnlejfabil) and help us find bugs before they're released to the public. 58 | 59 | ### 🌎 Translation 60 | 61 | Welcome every language support. Help your countryman to use this application. Ref [CONTRIBUTING - Working with translations](.github/CONTRIBUTING.md#working-with-translations) 62 | 63 | ## 📣 Declaration 64 | 65 | - **It's an unofficial project.** 66 | - **We would not collect any user data from Messenger.** 67 | 68 | ## 👨‍💻 Development 69 | 70 | ```bash 71 | yarn # install dependencies 72 | # Chrome 73 | npm run dev # for development 74 | npm run build-chrome # for production 75 | # Firefox 76 | npm run dev-firefox # for development 77 | npm run build-firefox # for production 78 | ``` 79 | 80 | ## ☕ Donation 81 | 82 | If you like this project, you can give me a cup of coffee 🙂 83 | 84 | [![paypal](https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif)](https://www.paypal.me/ALiangLiang/5) 85 | Buy Me A Coffee 86 | -------------------------------------------------------------------------------- /src/tab/lib/fetchThread.js: -------------------------------------------------------------------------------- 1 | /// ////////////////////////////////////////////////////////////////////// 2 | // Fetch threads information used by Messenger native API. But this API // 3 | // cannot get Messages. // 4 | /// ////////////////////////////////////////////////////////////////////// 5 | import _get from 'lodash/get' 6 | import _toString from 'lodash/toString' 7 | import User from '../classes/User' 8 | import { graphql, getQraphqlForm } from './util' 9 | const __ = chrome.i18n.getMessage 10 | 11 | function formatParticipant (participant, threadNode) { 12 | // Mapping participant nickname from threadNode.customization_info 13 | const participantCustomizations = (threadNode && threadNode.customization_info) 14 | ? threadNode.customization_info 15 | .participant_customizations 16 | .find((participantCustomization) => participantCustomization.participant_id === participant.id) 17 | : null 18 | 19 | return { 20 | id: participant.id, 21 | name: participant.name, 22 | nickname: (participantCustomizations) ? participantCustomizations.nickname : null, 23 | type: _get(participant, '__typename', '').toUpperCase(), 24 | url: participant.url, 25 | gender: participant.gender, 26 | shortName: participant.short_name, 27 | username: participant.username, 28 | avatar: (participant.big_image_src) ? participant.big_image_src.uri : null, 29 | isViewerFriend: participant.is_viewer_friend, 30 | isMessengerUser: participant.is_messenger_user, 31 | isVerified: participant.is_verified, 32 | isNessageBlockedByViewer: participant.is_message_blocked_by_viewer, 33 | isViewerCoworker: participant.is_viewer_coworker, 34 | isEmployee: participant.is_employee 35 | } 36 | } 37 | 38 | function formatThread (threadNode) { 39 | const participants = threadNode.all_participants.nodes 40 | .map((participant) => { 41 | const user = new User(formatParticipant(participant.messaging_actor, threadNode)) 42 | return { 43 | user, 44 | messageCount: null, 45 | characterCount: null, 46 | inGroup: true 47 | } 48 | }) 49 | let type, threadName 50 | if (threadNode.thread_type === 'ONE_TO_ONE') { 51 | const otherUserId = threadNode.other_user_id 52 | const otherUser = participants.find((participant) => 53 | participant.user.id !== otherUserId) 54 | type = (otherUser.user.type) ? otherUser.user.type.toUpperCase() : 'USER' 55 | 56 | if (!otherUser.user.type) console.warn(otherUser) 57 | 58 | threadName = otherUser.user.nickname || otherUser.user.name 59 | } else if (threadNode.thread_type === 'GROUP') { 60 | type = threadNode.thread_type 61 | 62 | // 如果沒有 thread 名稱,代表是沒有設定名稱的團體。 63 | // If no thread name, means it's no setting name group. 64 | threadName = (threadNode.name) 65 | ? threadNode.name 66 | : participants.slice(0, 3) 67 | .map((participant) => participant.user.name).join(__('comma')) + 68 | `${__('comma')}${__('others', String(participants.length - 3))}` 69 | } else { 70 | console.warn('Unknown thread type: ', threadNode) 71 | // TODO: handle with thread type 'ROOM' and 'MARKETPLACE' 72 | return null 73 | } 74 | 75 | return { 76 | id: threadNode.thread_key.other_user_id || threadNode.thread_key.thread_fbid, 77 | threadName, 78 | name: threadNode.name, 79 | image: (threadNode.image) ? threadNode.image.uri : null, 80 | emoji: _get(threadNode, 'customization_info.emoji', null), 81 | color: _toString(_get(threadNode, 'customization_info.outgoing_bubble_color', null)).replace(/^FF/, '#') || null, 82 | type, 83 | tag: threadNode.folder, 84 | muteUntil: (threadNode.mute_until === -1) ? Infinity : threadNode.mute_until, 85 | participants, 86 | otherUserId: threadNode.other_user_id, 87 | messageCount: threadNode.messages_count, 88 | characterCount: null 89 | } 90 | } 91 | 92 | export default async function fetchThread (jar, threadID) { 93 | // Prepare request form body. 94 | const queries = { 95 | o0: { 96 | doc_id: '1498317363570230', 97 | query_params: { 98 | id: threadID, 99 | message_limit: 0, 100 | load_messages: 0, 101 | load_read_receipts: false, 102 | before: null 103 | } 104 | } 105 | } 106 | const form = getQraphqlForm({ queries }, jar) 107 | const json = await graphql('https://www.facebook.com/api/graphqlbatch/', form) 108 | 109 | const threadData = json.o0.data.message_thread 110 | const thread = formatThread(threadData) 111 | return thread 112 | } 113 | -------------------------------------------------------------------------------- /src/tab/lib/generateCanvas.js: -------------------------------------------------------------------------------- 1 | import { 2 | getAvatar, 3 | getTextWidth, 4 | adjustTextSize 5 | } from './util.js' 6 | 7 | const __ = chrome.i18n.getMessage 8 | 9 | export default async function generateCanvas (thread, index, imageSize, jar) { 10 | function loadImage (src) { 11 | const img = new Image() 12 | img.crossOrigin = 'Anonymous' 13 | img.src = src 14 | return new Promise((resolve, reject) => (img.onload = () => resolve(img))) 15 | } 16 | 17 | try { 18 | const paddingTop = 70 19 | const avatarWidth = 250 20 | const userNameSize = 40 21 | const padding = [20, 25] 22 | const lineHeight = 15 23 | const avatarPaddingAside = 70 24 | const avatarPos = [ 25 | [avatarPaddingAside, paddingTop], 26 | [imageSize.width - avatarWidth - avatarPaddingAside, paddingTop] 27 | ] 28 | const fontSet = 'Verdana, Microsoft JhengHei' 29 | let textOffsetY = paddingTop + 40 30 | 31 | // draw sharing image 32 | const canvas = document.createElement('canvas') 33 | canvas.width = imageSize.width 34 | canvas.height = imageSize.height 35 | const ctx = canvas.getContext('2d') 36 | 37 | // paste background 38 | ctx.fillStyle = 'rgb(0, 131, 255, 0.8)' 39 | ctx.fillRect(0, 0, imageSize.width, imageSize.height) 40 | 41 | // set logo 42 | const logoText = `${__('extName')} (${__('unofficial')})` 43 | const logoTextSize = 40 44 | const logoTextPosY = canvas.height - padding[1] 45 | const generatedByPostfixPosX = canvas.width - padding[0] 46 | const generatedByFontSet = `30px ${fontSet}` 47 | const logoTextRightPosX = generatedByPostfixPosX - 48 | ((__('generatedByPostfix')) ? getTextWidth(__('generatedByPostfix'), generatedByFontSet) + 20 : 0) 49 | ctx.font = `${logoTextSize}px ${fontSet}` 50 | ctx.fillStyle = '#fff' 51 | ctx.textAlign = 'right' 52 | ctx.fillText(logoText, logoTextRightPosX, logoTextPosY) 53 | const logoWidth = 50 54 | const logoPos = [ 55 | logoTextRightPosX - getTextWidth(logoText, ctx.font) - logoWidth - 15, 56 | canvas.height - logoWidth + (logoWidth - logoTextSize) / 2 - padding[1] + 5 57 | ] 58 | ctx.drawImage(await loadImage('../icons/128.png'), logoPos[0], logoPos[1], logoWidth, logoWidth) 59 | // write "generate by" 60 | ctx.font = generatedByFontSet 61 | ctx.fillText(__('generatedByPrefix'), logoPos[0] - 20, logoTextPosY) 62 | ctx.fillText(__('generatedByPostfix'), generatedByPostfixPosX, logoTextPosY) 63 | 64 | const users = thread.participants 65 | .map((participant) => participant.user) 66 | .sort((user) => (user.id === jar.selfId) ? -1 : 0) 67 | .reverse() 68 | const images = await Promise.all( 69 | users.map(async (user) => loadImage(await getAvatar(jar, user)))) 70 | const [leftUser, rightUser] = users 71 | 72 | // write user name 73 | ctx.fillStyle = '#fff' 74 | ctx.textAlign = 'center' 75 | users.forEach((user, i) => { 76 | ctx.font = `${adjustTextSize(user.name, userNameSize, avatarWidth, fontSet)}px ${fontSet}` 77 | ctx.fillText(user.name 78 | , avatarPos[i][0] + avatarWidth / 2 79 | , avatarPos[i][1] + avatarWidth + userNameSize + 30) 80 | }) 81 | 82 | // write message count 83 | // "They have" 84 | ctx.font = `60px ${fontSet}` 85 | ctx.fillStyle = '#fff' 86 | ctx.textAlign = 'center' 87 | ctx.fillText(__('countPrefix'), imageSize.width / 2, textOffsetY += 60 + lineHeight) 88 | // message count background 89 | const barHeight = 120 90 | ctx.fillStyle = '#fff' 91 | ctx.fillRect(0, textOffsetY + lineHeight * 2, imageSize.width, barHeight) 92 | // message count text 93 | ctx.fillStyle = 'rgb(0, 131, 255)' 94 | ctx.font = `${barHeight}px ${fontSet}` 95 | ctx.fillText(thread.messageCount, imageSize.width / 2, textOffsetY += barHeight + lineHeight) 96 | // "messages!" 97 | ctx.fillStyle = '#fff' 98 | ctx.font = `60px ${fontSet}` 99 | ctx.fillText(__('countPostfix'), imageSize.width / 2, textOffsetY += 60 + lineHeight) 100 | // rank 101 | const rankText = __('rank', [leftUser.name, index + 1, rightUser.name]) 102 | ctx.font = `bold ${adjustTextSize(rankText, 60, canvas.width - 100, fontSet)}px ${fontSet}` 103 | ctx.fillText(rankText, imageSize.width / 2, textOffsetY += 60 + 50) 104 | 105 | // placed avatars 106 | images.forEach((image, i) => { 107 | const outerBorderWidth = 2 108 | const borderWidth = 8 109 | // draw black outter border 110 | ctx.fillStyle = 'rgba(0, 0, 0, .4)' 111 | ctx.fillRect( 112 | avatarPos[i][0] - borderWidth - outerBorderWidth, 113 | avatarPos[i][1] - borderWidth - outerBorderWidth, 114 | avatarWidth + borderWidth * 2 + outerBorderWidth * 2, 115 | avatarWidth + borderWidth * 2 + outerBorderWidth * 2) 116 | // draw white inner border 117 | ctx.fillStyle = '#fff' 118 | ctx.fillRect( 119 | avatarPos[i][0] - borderWidth, 120 | avatarPos[i][1] - borderWidth, 121 | avatarWidth + borderWidth * 2, 122 | avatarWidth + borderWidth * 2) 123 | // paste avatar 124 | ctx.drawImage(image, avatarPos[i][0], avatarPos[i][1], avatarWidth, avatarWidth) 125 | }) 126 | 127 | return canvas 128 | } catch (err) { 129 | console.error(err) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/tab/lib/fetchThreads.js: -------------------------------------------------------------------------------- 1 | /// ////////////////////////////////////////////////////////////////////// 2 | // Fetch threads information used by Messenger native API. But this API // 3 | // cannot get Messages. // 4 | /// ////////////////////////////////////////////////////////////////////// 5 | import _get from 'lodash/get' 6 | import _toString from 'lodash/toString' 7 | import Threads from '../classes/Threads' 8 | import Thread from '../classes/Thread' 9 | import User from '../classes/User' 10 | import { graphql, getQraphqlForm } from './util' 11 | const __ = chrome.i18n.getMessage 12 | 13 | function formatParticipant (participant, threadNode) { 14 | // Mapping participant nickname from threadNode.customization_info 15 | const participantCustomizations = (threadNode && threadNode.customization_info) 16 | ? threadNode.customization_info 17 | .participant_customizations 18 | .find((participantCustomization) => participantCustomization.participant_id === participant.id) 19 | : null 20 | 21 | return { 22 | id: participant.id, 23 | name: participant.name, 24 | nickname: (participantCustomizations) ? participantCustomizations.nickname : null, 25 | type: _get(participant, '__typename', '').toUpperCase(), 26 | url: participant.url, 27 | gender: participant.gender, 28 | shortName: participant.short_name, 29 | username: participant.username, 30 | avatar: (participant.big_image_src) ? participant.big_image_src.uri : null, 31 | isViewerFriend: participant.is_viewer_friend, 32 | isMessengerUser: participant.is_messenger_user, 33 | isVerified: participant.is_verified, 34 | isNessageBlockedByViewer: participant.is_message_blocked_by_viewer, 35 | isViewerCoworker: participant.is_viewer_coworker, 36 | isEmployee: participant.is_employee 37 | } 38 | } 39 | 40 | function formatThread (threadNode) { 41 | const participants = threadNode.all_participants.nodes 42 | .map((participant) => { 43 | const user = new User(formatParticipant(participant.messaging_actor, threadNode)) 44 | return { 45 | user, 46 | messageCount: null, 47 | characterCount: null, 48 | inGroup: true 49 | } 50 | }) 51 | let type, threadName 52 | if (threadNode.thread_type === 'ONE_TO_ONE') { 53 | const otherUserId = threadNode.other_user_id 54 | const otherUser = participants.find((participant) => 55 | participant.user.id !== otherUserId) 56 | type = (otherUser.user.type) ? otherUser.user.type.toUpperCase() : 'USER' 57 | 58 | if (!otherUser.user.type) console.warn(otherUser) 59 | 60 | threadName = otherUser.user.nickname || otherUser.user.name 61 | } else if (threadNode.thread_type === 'GROUP') { 62 | type = threadNode.thread_type 63 | 64 | // 如果沒有 thread 名稱,代表是沒有設定名稱的團體。 65 | // If no thread name, means it's no setting name group. 66 | threadName = (threadNode.name) 67 | ? threadNode.name 68 | : participants.slice(0, 3) 69 | .map((participant) => participant.user.name).join(__('comma')) + 70 | `${__('comma')}${__('others', String(participants.length - 3))}` 71 | } else { 72 | console.warn('Unknown thread type: ', threadNode) 73 | // TODO: handle with thread type 'ROOM' and 'MARKETPLACE' 74 | return null 75 | } 76 | 77 | return { 78 | id: threadNode.thread_key.other_user_id || threadNode.thread_key.thread_fbid, 79 | threadName, 80 | name: threadNode.name, 81 | image: (threadNode.image) ? threadNode.image.uri : null, 82 | emoji: _get(threadNode, 'customization_info.emoji', null), 83 | color: _toString(_get(threadNode, 'customization_info.outgoing_bubble_color', null)).replace(/^FF/, '#') || null, 84 | type, 85 | muteUntil: (threadNode.mute_until === -1) ? Infinity : threadNode.mute_until, 86 | participants, 87 | otherUserId: threadNode.other_user_id, 88 | messageCount: threadNode.messages_count, 89 | characterCount: null 90 | } 91 | } 92 | 93 | function createThreadObject (threadNode, createdUsers, tag) { 94 | const thread = formatThread(threadNode) 95 | return new Thread(Object.assign({ tag }, thread)) 96 | } 97 | 98 | export default async function fetchThreads (jar, limit = 5000, tags = ['INBOX', 'ARCHIVED', 'PENDING']) { 99 | const threadsData = (await Promise.all(tags.map(async (tag) => { 100 | // Prepare request form body. 101 | const queries = { 102 | o0: { 103 | doc_id: '1349387578499440', 104 | query_params: { 105 | limit, 106 | before: null, 107 | tags: [tag], 108 | isWorkUser: false, 109 | includeDeliveryReceipts: true, 110 | includeSeqID: false 111 | } 112 | } 113 | } 114 | const form = getQraphqlForm({ queries }, jar) 115 | const json = await graphql('https://www.facebook.com/api/graphqlbatch/', form) 116 | 117 | const createdUsers = [] // Use to record user we created. 118 | return json.o0.data.viewer.message_threads.nodes 119 | .map((threadsData) => createThreadObject(threadsData, createdUsers, tag)) 120 | .filter((thread) => !!thread) 121 | }))) 122 | .reduce((cur, threadsData) => cur.concat(threadsData), []) 123 | // Sort by message count. From more to less. 124 | .sort((threadA, threadB) => threadB.messageCount - threadA.messageCount) 125 | 126 | return new Threads(threadsData) 127 | } 128 | -------------------------------------------------------------------------------- /src/_locales/zh_TW/messages.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // 擴充套件資訊 3 | extName: { message: 'Messenger 計數器' }, 4 | extDescription: { message: '統計並排名你在 Messenger 中與朋友的訊息量!也可以順便打包下載訊息記錄。' }, 5 | unofficial: { message: '非官方' }, 6 | 7 | // 路由 8 | listPage: { message: '清單' }, 9 | chartPage: { message: '圖表' }, 10 | shareOnFacebook: { message: '分享至 Facebook' }, 11 | 12 | // Loading 訊息 13 | interceptingToken: { message: '取得權杖中...' }, 14 | fetchingThreads: { message: '抓取訊息總數中...' }, 15 | fetchingMessages: { message: '抓取訊息中...' }, 16 | rendering: { message: '渲染中...' }, 17 | waitingForLogin: { message: '等待登入中...' }, 18 | 19 | // 提醒視窗 20 | openingAlertTitle: { message: '請保持耐心' }, 21 | openingAlertContent: { message: '從 FB 撈取資料可能會花費許多時間。' }, 22 | resetConfirmTitle: { message: '確定?' }, 23 | resetConfirmContent: { message: '你確定要清除所有快取資料?' }, 24 | error: { message: '錯誤' }, 25 | cannotDetectLoginContent: { message: '無法偵測登入 Facebook。' }, 26 | editNicknameTitle: { message: '編輯暱稱' }, // ThreadList/DetailTemplate 27 | editNicknameContent: { message: '此對話中的所有人都會看到這個。' }, // ThreadList/DetailTemplate 28 | iSee: { message: '我瞭解' }, 29 | ok: { message: '確定' }, 30 | sure: { message: '確定' }, 31 | refresh: { message: '重新整理' }, 32 | cancel: { message: '取消' }, 33 | 34 | // About Page 35 | support: { message: '支援' }, 36 | support1Title: { message: '發現錯誤?或是有新的點子?' }, 37 | support1Content: { 38 | message: '來這裡回報吧。', 39 | placeholders: { url: { content: '$1' } } 40 | }, 41 | support2Title: { message: '喜歡這支擴充功能嗎?' }, 42 | support2Content: { 43 | message: '在 Chrome web store 上評論來讓我知道吧。😄', 44 | placeholders: { url: { content: '$1' } } 45 | }, 46 | support3Title: { message: '想協助新增功能或是新增翻譯?' }, 47 | support3Content: { 48 | message: '為何不乾脆在 github 上 fork 一份回來改呢?', 49 | placeholders: { github: { content: '$1' } } 50 | }, 51 | QA: { message: '常見問題' }, 52 | QA1Title: { message: '我的資訊安全嗎?' }, 53 | QA1Content: { message: '甭擔心,這支擴充功能僅會替您將資料從 Messenger 抓到您的本機裝置中,如果您使用的是公共裝置,建議離開前將此擴充功能刪除,或是按下「重置」鈕並關閉頁面。' }, 54 | QA2Title: { message: '如何幫助這支擴充功能?' }, 55 | QA2Content: { 56 | message: '歡迎各種捐款來協助我提神改程式XD,或是告訴其他朋友來使用它;如果你也是個開發者或是熱愛新科技,歡迎使用 公開測試版本來協助我們在正式發布前,找到錯誤。', 57 | placeholders: { donation_url: { content: '$1' }, beta_url: { content: '$2' } } 58 | }, 59 | QA3Title: { message: '如何變更 Facebook 帳號' }, 60 | QA3Content: { message: '只要打開 Facebook 頁面,登出並使用另一個帳號登入,再重新整理或開啟這個頁面就可以囉。' }, 61 | note: { message: '作者的話' }, 62 | noteContent: { message: '首先,這是一個擴充功能你知道的,啟發自我想知道,我跟「朋友」們之間有多少訊息量,所以才開始著手這套小小的專案,剛開始時非常陽春且難看,但是到了最近越來越多人安裝使用,鼓勵了我繼續開發維護改進它,當然爾我不會辜負各位,未來為持續更新這個專案,讓它有更多功能。最後,如果你歡這支擴充功能,歡迎給予5星評價,我會很開心的XDDD,謝謝各位。' }, 63 | 64 | // 清單 65 | threadName: { message: '名稱' }, 66 | threadType: { message: '種類' }, 67 | threadTag: { message: '標籤' }, 68 | threadMessageCount: { message: '訊息數量' }, 69 | threadCharacterCount: { message: '文字數量' }, 70 | threadOperation: { message: '操作' }, 71 | importMessageHistory: { message: '載入訊息記錄' }, 72 | importedMessageHistory: { message: '已載入' }, 73 | downloadMessageHistory: { message: '儲存/下載訊息記錄' }, 74 | totalMessageCount: { message: '總計訊息數量' }, 75 | user: { message: '用戶' }, 76 | fanpage: { message: '粉絲專頁' }, 77 | group: { message: '群組' }, 78 | inbox: { message: '收件匣' }, 79 | archived: { message: '封存' }, 80 | pending: { message: '陌生' }, 81 | unknown: { message: '未知' }, 82 | fetchDetailOfselected: { message: '載入所選訊息的記錄' }, 83 | reset: { message: '清除所有資料' }, 84 | searchInputLabel: { message: '搜尋' }, 85 | searchInputPlaceholder: { message: '請輸入關鍵字' }, 86 | edit: { message: '編輯' }, 87 | emoji: { message: '表情符號' }, 88 | color: { message: '顏色' }, 89 | participants: { message: '成員' }, 90 | generateSharingImage: { message: '產生分享圖' }, 91 | shareToFb: { message: '分享到 Facebook' }, 92 | // MuteUntil 元件 93 | muteUntil: { message: '關閉通知直到' }, 94 | muteForever: { message: '世界末日' }, 95 | unmute: { message: '開啟通知' }, 96 | minutes: { message: '分鐘' }, 97 | hour: { message: '小時' }, 98 | hours: { message: '小時' }, 99 | day: { message: '天' }, 100 | week: { message: '週' }, 101 | month: { message: '個月' }, 102 | always: { message: '永遠' }, 103 | 104 | // 圖表 105 | operationBar: { message: '工具欄' }, 106 | drapToLookOtherUsers: { message: '滑動以查看其他排名' }, 107 | showDetail: { message: '顯示詳細' }, 108 | showTotal: { message: '顯示總和' }, 109 | showMessage: { message: '顯示訊息數量' }, 110 | showCharacter: { message: '顯示文字數量' }, 111 | detail: { message: '詳細' }, 112 | total: { message: '總共' }, 113 | message: { message: '訊息數量' }, 114 | character: { message: '文字數量' }, 115 | me: { message: '我' }, 116 | other: { message: '其他人' }, 117 | 118 | // 分享對話視窗 119 | countPrefix: { message: '他們之間有' }, 120 | countPostfix: { message: ' 則訊息!!' }, 121 | rank: { 122 | message: '$friend$ 是 $self$ 好友之中的第 $rank$ 名', 123 | placeholders: { self: { content: '$1' }, rank: { content: '$2' }, friend: { content: '$3' } } 124 | }, 125 | generatedByPrefix: { message: '使用' }, 126 | generatedByPostfix: { message: '統計' }, 127 | download: { message: '下載' }, 128 | 129 | // 錯誤訊息 130 | fetchError: { message: '啊!撈取資料時發生錯誤。' }, 131 | contactDevelper: { message: '請聯絡開發者以解決問題。' }, 132 | loginRequired: { message: '請先登入您的 Facebook。' }, 133 | 134 | // 符號 135 | comma: { message: '、' }, 136 | colon: { message: ':' }, 137 | 138 | // Thread 物件 139 | others: { message: '其他$1個人' }, 140 | 141 | // Content 腳本的提示訊息 142 | loginAlert: { message: '請登入 Messenger 網頁版以統計您的訊息。' } 143 | } 144 | -------------------------------------------------------------------------------- /core/webpack.base.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | const ChromeReloadPlugin = require('wcer') 4 | const { htmlPage } = require('./tools') 5 | const CopyWebpackPlugin = require('copy-webpack-plugin') 6 | // const { CleanWebpackPlugin } = require('clean-webpack-plugin') 7 | const VueLoaderPlugin = require('vue-loader/lib/plugin') 8 | const GenerateLocaleJsonPlugin = require('../plugins/GenerateLocaleJsonPlugin') 9 | 10 | const rootDir = path.resolve(__dirname, '..') 11 | 12 | const resolve = (dir) => path.join(rootDir, 'src', dir) 13 | 14 | module.exports = (env) => { 15 | Object.assign(process.env, env) 16 | return { 17 | entry: { 18 | tab: resolve('./tab'), 19 | options: resolve('./options'), 20 | content: resolve('./content'), 21 | background: resolve('./backend') 22 | }, 23 | output: { 24 | path: path.join(rootDir, 'build', (!env.FIREFOX) ? 'chrome' : 'firefox'), 25 | publicPath: '/', 26 | filename: 'js/[name].js', 27 | chunkFilename: 'js/[id].[name].js?[hash]', 28 | library: '[name]' 29 | }, 30 | resolve: { 31 | alias: { 32 | vue$: 'vue/dist/vue.esm.js', 33 | '@': resolve('') 34 | }, 35 | extensions: ['.js', '.vue', '.json'] 36 | }, 37 | optimization: { 38 | // Coz mozilla(firefox) addon store cannot accept single file bigger than 4mb, separate it. 39 | splitChunks: { 40 | chunks: 'initial', 41 | cacheGroups: { 42 | vendor: { 43 | test: /[\\/]node_modules[\\/]/, 44 | chunks: 'initial', 45 | priority: -20, 46 | reuseExistingChunk: false 47 | }, 48 | element: { 49 | test: /[\\/]node_modules[\\/]element-ui[\\/]/, 50 | chunks: 'initial', 51 | priority: -10 52 | }, 53 | chartjs: { 54 | test: /[\\/]node_modules[\\/]chart\.js[\\/]/, 55 | chunks: 'initial', 56 | priority: 0 57 | } 58 | } 59 | } 60 | }, 61 | module: { 62 | rules: [{ 63 | test: /\.(js|vue)$/, 64 | loader: 'eslint-loader', 65 | enforce: 'pre', 66 | include: [path.join(rootDir, 'src')], 67 | options: { formatter: require('eslint-friendly-formatter') } 68 | }, { 69 | test: /\.vue$/, 70 | loader: 'vue-loader', 71 | options: { 72 | extractCSS: true, 73 | loaders: { 74 | js: { loader: 'babel-loader' } 75 | }, 76 | transformToRequire: { 77 | video: 'src', 78 | source: 'src', 79 | img: 'src', 80 | image: 'xlink:href' 81 | } 82 | } 83 | }, { 84 | test: /\.js$/, 85 | loader: 'babel-loader', 86 | include: [ 87 | path.join(rootDir, 'src'), 88 | path.join(rootDir, 'node_modules', 'vue-awesome'), 89 | path.join(rootDir, 'node_modules', 'element-ui', 'src/utils/popup/popup-manager.js'), 90 | path.join(rootDir, 'node_modules', 'element-ui', 'src/utils/popup/index.js'), 91 | path.join(rootDir, 'node_modules', 'element-ui', 'src/utils/merge.js'), 92 | path.join(rootDir, 'node_modules', 'element-ui', 'src/utils/scrollbar-width.js'), 93 | path.join(rootDir, 'node_modules', 'element-ui', 'src/utils/vue-popper.js'), 94 | path.join(rootDir, 'node_modules', 'element-ui', 'src/utils/clickoutside.js') 95 | ] 96 | }, { 97 | test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, 98 | loader: 'url-loader', 99 | options: { 100 | limit: 10000, 101 | name: 'img/[name].[hash:7].[ext]' 102 | } 103 | }, { 104 | test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/, 105 | loader: 'url-loader', 106 | options: { 107 | limit: 10000, 108 | name: 'media/[name].[hash:7].[ext]' 109 | } 110 | }, { 111 | test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, 112 | loader: 'url-loader', 113 | options: { 114 | limit: 10000, 115 | name: 'fonts/[name].[hash:7].[ext]' 116 | } 117 | }] 118 | }, 119 | plugins: [ 120 | // new CleanWebpackPlugin({ root: path.join(rootDir, 'build', (env.FIREFOX) ? 'firefox' : 'chrome') }), 121 | new webpack.DefinePlugin({ 122 | chrome: (!env.FIREFOX) ? 'chrome' : 'browser', 123 | 'process.env.NODE_ENV': `"${env.NODE_ENV}"`, 124 | 'process.env.CHROME': !!env.CHROME, 125 | 'process.env.FIREFOX': !!env.FIREFOX, 126 | 'process.env.BETA': !!env.BETA, 127 | 'process.env.ALPHA': !!env.ALPHA, 128 | 'process.env.DEV': (env.NODE_ENV === 'development') 129 | }), 130 | new VueLoaderPlugin(), 131 | htmlPage('Counter for Messenger', 'app', ['vendors~background~tab', 'vendors~options~tab', 'vendors~tab', 'chartjs~tab', 'chartjs', 'tab']), 132 | htmlPage('options', 'options', ['vendors~options~tab', 'vendors~options', 'options']), 133 | htmlPage('background', 'background', ['vendors~background~tab', 'background']), 134 | new CopyWebpackPlugin([{ from: path.join(rootDir, 'static') }]), 135 | new ChromeReloadPlugin({ 136 | port: (!env.FIREFOX) ? 9090 : 9091, 137 | manifest: path.join(rootDir, 'src', 'manifest.js') 138 | }), 139 | new GenerateLocaleJsonPlugin({ 140 | _locales: path.join(rootDir, 'src', '_locales') 141 | }), 142 | // never use locales of moment.js, so don't include it. 143 | new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/) 144 | ], 145 | performance: { hints: false } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/tab/lib/changeThreadSetting.js: -------------------------------------------------------------------------------- 1 | /// ////////////////////////////////////////////////////////////////////// 2 | // Fetch threads information used by Messenger native API. But this API // 3 | // cannot get Messages. // 4 | /// ////////////////////////////////////////////////////////////////////// 5 | import { 6 | graphql, 7 | getMessengerApiForm, 8 | generateOfflineThreadingID, 9 | uploadImage 10 | } from './util' 11 | 12 | export function changeThreadName (jar, thread, newThreadName) { 13 | const offlineThreadingId = generateOfflineThreadingID() 14 | const formData = { 15 | client: 'messenger', 16 | action_type: 'ma-type:log-message', 17 | ephemeral_ttl_mode: 0, 18 | 'log_message_data[name]': newThreadName, 19 | log_message_type: 'log:thread-name', 20 | message_id: offlineThreadingId, 21 | offline_threading_id: offlineThreadingId, 22 | source: 'source:chat:web', 23 | thread_fbid: thread.id, 24 | timestamp: Date.now() 25 | } 26 | const form = getMessengerApiForm(formData, jar) 27 | return graphql('https://www.facebook.com/messaging/send/?dpr=1', form) 28 | } 29 | 30 | export function changeThreadNickname (jar, thread, participantId, nickname) { 31 | const formData = { 32 | thread_or_other_fbid: thread.id, 33 | participant_id: participantId, 34 | nickname 35 | } 36 | const form = getMessengerApiForm(formData, jar) 37 | return graphql('https://www.facebook.com/messaging/save_thread_nickname/?source=thread_settings&dpr=1', form) 38 | } 39 | 40 | export async function changeThreadImage (jar, thread, image) { 41 | const metadata = (await uploadImage(jar, image)).payload.metadata[0] 42 | const offlineThreadingId = generateOfflineThreadingID() 43 | console.log(metadata.src) 44 | const formData = { 45 | client: 'mercury', 46 | action_type: 'ma-type:log-message', 47 | ephemeral_ttl_mode: 0, 48 | 'log_message_data[image][image_id]': metadata.image_id, 49 | 'log_message_data[image][filename]': metadata.filename, 50 | 'log_message_data[image][filetype]': metadata.filetype, 51 | 'log_message_data[image][src]': metadata.src, 52 | 'log_message_data[image][fbid]': metadata.fbid, 53 | log_message_type: 'log:thread-image', 54 | message_id: offlineThreadingId, 55 | offline_threading_id: offlineThreadingId, 56 | source: 'source:messenger:web', 57 | thread_fbid: thread.id, 58 | timestamp: Date.now() 59 | } 60 | const form = getMessengerApiForm(formData, jar) 61 | return graphql('https://www.facebook.com/messaging/send/?dpr=1', form) 62 | } 63 | 64 | export async function muteThread (jar, thread, muteSeconds) { 65 | const formData = { 66 | thread_fbid: thread.id, 67 | mute_settings: muteSeconds 68 | } 69 | const form = getMessengerApiForm(formData, jar) 70 | return graphql('https://www.facebook.com/ajax/mercury/change_mute_thread.php', form) 71 | } 72 | 73 | export const EMOJIS = ['\u270c', '\ud83d\ude02', '\ud83d\ude1d', '\ud83d\ude01', '\ud83d\ude31', '\ud83d\udc49', '\ud83d\ude4c', '\ud83c\udf7b', '\ud83d\udd25', '\ud83c\udf08', '\u2600', '\ud83c\udf88', '\ud83c\udf39', '\ud83d\udc84', '\ud83c\udf80', '\u26bd', '\ud83c\udfbe', '\ud83c\udfc1', '\ud83d\ude21', '\ud83d\udc7f', '\ud83d\udc3b', '\ud83d\udc36', '\ud83d\udc2c', '\ud83d\udc1f', '\ud83c\udf40', '\ud83d\udc40', '\ud83d\ude97', '\ud83c\udf4e', '\ud83d\udc9d', '\ud83d\udc99', '\ud83d\udc4c', '\u2764', '\ud83d\ude0d', '\ud83d\ude09', '\ud83d\ude13', '\ud83d\ude33', '\ud83d\udcaa', '\ud83d\udca9', '\ud83c\udf78', '\ud83d\udd11', '\ud83d\udc96', '\ud83c\udf1f', '\ud83c\udf89', '\ud83c\udf3a', '\ud83c\udfb6', '\ud83d\udc60', '\ud83c\udfc8', '\u26be', '\ud83c\udfc6', '\ud83d\udc7d', '\ud83d\udc80', '\ud83d\udc35', '\ud83d\udc2e', '\ud83d\udc29', '\ud83d\udc0e', '\ud83d\udca3', '\ud83d\udc43', '\ud83d\udc42', '\ud83c\udf53', '\ud83d\udc98', '\ud83d\udc9c', '\ud83d\udc4a', '\ud83d\udc8b', '\ud83d\ude18', '\ud83d\ude1c', '\ud83d\ude35', '\ud83d\ude4f', '\ud83d\udc4b', '\ud83d\udebd', '\ud83d\udc83', '\ud83d\udc8e', '\ud83d\ude80', '\ud83c\udf19', '\ud83c\udf81', '\u26c4', '\ud83c\udf0a', '\u26f5', '\ud83c\udfc0', '\ud83c\udfb1', '\ud83d\udcb0', '\ud83d\udc76', '\ud83d\udc78', '\ud83d\udc30', '\ud83d\udc37', '\ud83d\udc0d', '\ud83d\udc2b', '\ud83d\udd2b', '\ud83d\udc44', '\ud83d\udeb2', '\ud83c\udf49', '\ud83d\udc9b', '\ud83d\udc9a'] 74 | 75 | export function changeThreadEmoji (jar, thread, newEmoji) { 76 | const formData = { 77 | thread_or_other_fbid: thread.id 78 | } 79 | // If no new emoji set, Messenger will set to default emoji(thumbs up). 80 | if (newEmoji) { 81 | formData.emoji_choice = newEmoji.toLowerCase() 82 | } 83 | const form = getMessengerApiForm(formData, jar) 84 | return graphql('https://www.facebook.com/messaging/save_thread_emoji/?source=thread_settings&dpr=1', form) 85 | } 86 | 87 | export const COLORS = { 88 | '#0084ff': 'Messenger Blue', 89 | '#44bec7': 'Viking', 90 | '#ffc300': 'Golden Poppy', 91 | '#fa3c4c': 'Radical Red', 92 | '#d696bb': 'Shocking', 93 | '#6699cc': 'Picton Blue', 94 | '#13cf13': 'Free Speech Green', 95 | '#ff7e29': 'Pumpkin', 96 | '#e68585': 'Light Coral', 97 | '#7646ff': 'Medium Slate Blue', 98 | '#20cef5': 'Deep Sky Blue', 99 | '#67b868': 'Fern', 100 | '#d4a88c': 'Cameo', 101 | '#ff5ca1': 'Brilliant Rose', 102 | '#a695c7': 'Biloba Flower' 103 | // '#000000': 'Black' // Defined in Messenger page, but unuseful. 104 | } 105 | 106 | export function changeThreadColor (jar, thread, newColor) { 107 | const formData = { 108 | thread_or_other_fbid: thread.id 109 | } 110 | // If no new color set, Messenger will set to default color(Messenger Blue). 111 | if (newColor) { 112 | formData.color_choice = newColor.toLowerCase() 113 | } 114 | const form = getMessengerApiForm(formData, jar) 115 | return graphql('https://www.facebook.com/messaging/save_thread_color/?source=thread_settings&dpr=1', form) 116 | } 117 | -------------------------------------------------------------------------------- /src/tab/lib/util.js: -------------------------------------------------------------------------------- 1 | import Queue from 'promise-queue' 2 | 3 | const _queue = new Queue(40, Infinity) 4 | let reqCounter = 1 5 | 6 | // http get 7 | export function get (url) { 8 | return fetchService(url, { 9 | credentials: 'same-origin' 10 | }) 11 | } 12 | 13 | export function toQuerystring (form) { 14 | return Object.keys(form).map(function (key) { 15 | const val = (typeof form[key] === 'object') 16 | ? JSON.stringify(form[key]) : form[key] 17 | return encodeURIComponent(key) + 18 | ((form[key] !== undefined) ? ('=' + encodeURIComponent(val)) : '') 19 | }).join('&') 20 | } 21 | 22 | // http post 23 | export function post (url, form, headers) { 24 | let body 25 | if (form instanceof FormData) { 26 | body = form 27 | } else { 28 | // stringify value of form. 29 | body = toQuerystring(form) 30 | } 31 | 32 | return fetchService(url, { 33 | method: 'POST', 34 | headers, 35 | credentials: 'same-origin', 36 | body 37 | }) 38 | } 39 | 40 | export function graphql (url, form, headers = { 41 | 'content-type': 'application/x-www-form-urlencoded; charset=utf-8' 42 | }) { 43 | return post(url, form, headers) 44 | .then((res) => res.text()) 45 | // handle graphql response. 46 | .then((body) => JSON.parse(body.replace(/for\s*\(\s*;\s*;\s*\)\s*;\s*/, '').split('\n')[0])) 47 | .then((parsedBody) => { 48 | if (parsedBody.error) { 49 | const error = new Error(parsedBody.errorSummary) 50 | error.code = parsedBody.error 51 | error.description = parsedBody.errorDescription 52 | throw error 53 | } 54 | 55 | return parsedBody 56 | }) 57 | } 58 | 59 | export function uploadImage (jar, image) { 60 | const formData = new FormData() 61 | formData.append('fb_dtsg', jar.token) 62 | formData.append('images_only', true) 63 | formData.append('attachment[]', image) 64 | const params = getMessengerApiForm({}, jar) 65 | const querystring = toQuerystring(params) 66 | 67 | return graphql('https://upload.facebook.com/ajax/mercury/upload.php?' + querystring, formData, {}) 68 | } 69 | 70 | export async function getAvatar (jar, user) { 71 | const response = await get(`https://graph.facebook.com/v2.12/${user.id}/picture?type=large&redirect=false&width=400&height=400&access_token`) 72 | .then((res) => res.json()) 73 | return response.data.url 74 | } 75 | 76 | export function getMessengerApiForm (form, jar) { 77 | var ttstamp = '2' 78 | for (var i = 0; i < jar.token.length; i++) { 79 | ttstamp += jar.token.charCodeAt(i) 80 | } 81 | 82 | return Object.assign({ 83 | __req: (reqCounter++).toString(36), 84 | __rev: jar.revision, // optional 85 | __user: jar.selfId, 86 | __a: 1, 87 | fb_dtsg: jar.token, // It's required!! 88 | jazoest: ttstamp 89 | }, form) 90 | } 91 | 92 | export function getQraphqlForm (form, jar) { 93 | return Object.assign(getMessengerApiForm(form, jar), { 94 | batch_name: 'MessengerGraphQLThreadlistFetcher' 95 | }, form) 96 | } 97 | 98 | // extract form from html. 99 | export function getFrom (str, startToken, endToken) { 100 | var start = str.indexOf(startToken) + startToken.length 101 | if (start < startToken.length) return '' 102 | 103 | var lastHalf = str.substring(start) 104 | var end = lastHalf.indexOf(endToken) 105 | if (end === -1) { 106 | throw Error( 107 | 'Could not find endTime `' + endToken + '` in the given string.' 108 | ) 109 | } 110 | return lastHalf.substring(0, end) 111 | } 112 | 113 | /** 114 | * Extract request data from Facebook page. 115 | * @return {Promise<{token, selfId, revision}>} return a Promise include "token", "selfId" and "revision" 116 | */ 117 | export async function getJar () { 118 | // fetch facebook page and extract data from their. 119 | const res = await get('https://www.facebook.com/') 120 | const html = await res.text() 121 | 122 | // required 123 | const token = getFrom(html, 'name="fb_dtsg" value="', '"') 124 | const selfId = getFrom(html, 'name="xhpc_targetid" value="', '"') 125 | if (!token || !selfId) { 126 | throw new Error('Cannot extract from facebook page.') 127 | } 128 | 129 | // optional 130 | let revision 131 | try { revision = getFrom(html, 'revision":', ',') } catch (err) {} 132 | 133 | return { 134 | token, 135 | selfId, 136 | revision 137 | } 138 | } 139 | 140 | // A service to send request to API. Controll request flow to avoid become DDOS. 141 | export async function fetchService (...args) { 142 | return new Promise((resolve, reject) => { 143 | let wrapper = null 144 | try { 145 | wrapper = async () => { 146 | try { 147 | const response = await fetch(...args) 148 | if (!response.ok) throw new Error('No ok.') 149 | return resolve(response) 150 | } catch (err) { 151 | // console.error(err) 152 | return reject(err) 153 | } 154 | } 155 | } catch (err) { 156 | // console.error(err) 157 | return reject(err) 158 | } 159 | _queue.add(wrapper) 160 | }) 161 | } 162 | 163 | // Used to debug. How much fetch mission queue. How much fetch mission pending. 164 | // setInterval(() => console.log(_queue.getQueueLength(), _queue.getPendingLength()), 3000) 165 | 166 | function binaryToDecimal (data) { 167 | let ret = '' 168 | while (data !== '0') { 169 | let end = 0 170 | let fullName = '' 171 | let i = 0 172 | for (;i < data.length; i++) { 173 | end = 2 * end + parseInt(data[i], 10) 174 | if (end >= 10) { 175 | fullName += '1' 176 | end -= 10 177 | } else { 178 | fullName += '0' 179 | } 180 | } 181 | ret = end.toString() + ret 182 | data = fullName.slice(fullName.indexOf('1')) 183 | } 184 | return ret 185 | } 186 | 187 | export function generateOfflineThreadingID () { 188 | const ret = Date.now() 189 | const value = Math.floor(Math.random() * 4294967295) 190 | const str = ('0000000000000000000000' + value.toString(2)).slice(-22) 191 | const msgs = ret.toString(2) + str 192 | return binaryToDecimal(msgs) 193 | } 194 | 195 | export function getTextWidth (text, font) { 196 | // shamely copy from https://stackoverflow.com/questions/118241/calculate-text-width-with-javascript#answer-21015393 197 | const canvas = getTextWidth.canvas || (getTextWidth.canvas = document.createElement('canvas')) 198 | const context = canvas.getContext('2d') 199 | context.font = font 200 | const metrics = context.measureText(text) 201 | return metrics.width 202 | // End shamely copy 203 | } 204 | 205 | export function adjustTextSize (text, initialSize, maxTextWidth, fontSet = '', 206 | minSize = 30, step = 1) { 207 | let fontSize = initialSize 208 | while (getTextWidth(text, `${fontSize}px ${fontSet}`) >= maxTextWidth && 209 | fontSize >= minSize) { 210 | fontSize -= step 211 | console.log(fontSize) 212 | } 213 | return fontSize 214 | } 215 | -------------------------------------------------------------------------------- /src/_locales/en/messages.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Extension information 3 | extName: { message: 'Counter for Messenger' }, 4 | extDescription: { message: 'Count and rank your friends by analysing your Messenger! Check out and download the messaging history of you and your best friends!' }, 5 | unofficial: { message: 'Unofficial' }, 6 | 7 | // Router 8 | listPage: { message: 'List' }, 9 | chartPage: { message: 'Chart' }, 10 | shareOnFacebook: { message: 'Share on Facebook' }, 11 | 12 | // Message of loading 13 | interceptingToken: { message: 'Extracting token...' }, 14 | fetchingThreads: { message: 'Fetching threads...' }, 15 | fetchingMessages: { message: 'Fetching messages...' }, 16 | rendering: { message: 'Rendering...' }, 17 | waitingForLogin: { message: 'Waiting for log in...' }, 18 | 19 | // Alert model 20 | openingAlertTitle: { message: 'Please be patient' }, 21 | openingAlertContent: { message: 'Fetching data from FB may take a long time.' }, 22 | resetConfirmTitle: { message: 'Sure?' }, 23 | resetConfirmContent: { message: 'Are you sure you want to clear all cached data?' }, 24 | error: { message: 'Error' }, 25 | cannotDetectLoginContent: { message: 'Cannot detect facebook login.' }, 26 | editNicknameTitle: { message: 'Edit Nickname' }, // ThreadList/DetailTemplate 27 | editNicknameContent: { message: 'Everyone in this conversation will see this.' }, // ThreadList/DetailTemplate 28 | iSee: { message: 'Got it!' }, 29 | ok: { message: 'OK' }, 30 | sure: { message: 'Sure!!' }, 31 | refresh: { message: 'Refresh' }, 32 | cancel: { message: 'Cancel' }, 33 | 34 | // About Page 35 | support: { message: 'Support' }, 36 | support1Title: { message: 'Found a bug? Got a question or new idea?' }, 37 | support1Content: { 38 | message: 'Why don\'t you send us a report?', 39 | placeholders: { url: { content: '$1' } } 40 | }, 41 | support2Title: { message: 'Like this extension?' }, 42 | support2Content: { 43 | message: 'Let us know on Chrome web store!', 44 | placeholders: { url: { content: '$1' } } 45 | }, 46 | support3Title: { message: 'Want to add feature or help translating?' }, 47 | support3Content: { 48 | message: 'Why not fork in github? 😄', 49 | placeholders: { github: { content: '$1' } } 50 | }, 51 | QA: { message: 'Frequently Asked Questions' }, 52 | QA1Title: { message: 'Is my information safe?' }, 53 | QA1Content: { message: 'This extension only fetches your data from Messenger to your local device. If your device is not public, your information is safe. On the other hand, if you\'re using a public device, be sure to remove this extension after you finish using this extension. ' }, 54 | QA2Title: { message: 'How can I help Counter of Messenger?' }, 55 | QA2Content: { 56 | message: 'You can donate to support our work. You can tell others to try Counter of Messenger. And, if you\'re technically-savvy, you can use our beta version and help us find bugs before they\'re released to the public.', 57 | placeholders: { donation_url: { content: '$1' }, beta_url: { content: '$2' } } 58 | }, 59 | QA3Title: { message: 'How to switch FB account?' }, 60 | QA3Content: { message: 'Just changing your account in Facebook page and reload this extension page.' }, 61 | note: { message: 'Note' }, 62 | noteContent: { message: 'It\'a web extension you know. Inspired by the fact that I want to know how many messages I have with my best friends. In the beginning it was just a little project. Very simple and ugly. But recently, more and more users installed this extension, and encouraged me to develop it further. I don\'t want to disappoint them. So I\'ve updated this project, Making it more powerful and beautiful. If you really like this extension, tell me. I would very happy. 😄😄😄 Thanks everyone. ' }, 63 | 64 | // List Page 65 | threadName: { message: 'Name' }, 66 | threadType: { message: 'Type' }, 67 | threadTag: { message: 'Tag' }, 68 | threadMessageCount: { message: 'Messages' }, 69 | threadCharacterCount: { message: 'Characters' }, 70 | threadOperation: { message: 'Operations' }, 71 | importMessageHistory: { message: 'Import Messages' }, 72 | importedMessageHistory: { message: 'Imported' }, 73 | downloadMessageHistory: { message: 'Download Messages' }, 74 | totalMessageCount: { message: 'Total Messages' }, 75 | user: { message: 'User' }, 76 | fanpage: { message: 'Fanpage' }, 77 | group: { message: 'Group' }, 78 | inbox: { message: 'Inbox' }, 79 | archived: { message: 'Archived' }, 80 | pending: { message: 'Request' }, 81 | unknown: { message: 'Unknown' }, 82 | fetchDetailOfselected: { message: 'Import Messages of Selected Threads' }, 83 | reset: { message: 'Reset' }, 84 | searchInputLabel: { message: 'Search' }, 85 | searchInputPlaceholder: { message: 'Keyword' }, 86 | edit: { message: 'Edit' }, 87 | emoji: { message: 'Emoji' }, 88 | color: { message: 'Color' }, 89 | participants: { message: 'Participants' }, 90 | generateSharingImage: { message: 'Generate Shaing Image' }, 91 | shareToFb: { message: 'Share on Facebook' }, 92 | // MuteUntil component 93 | muteUntil: { message: 'Mute until' }, 94 | muteForever: { message: 'End of the world' }, 95 | unmute: { message: 'Unmute' }, 96 | minutes: { message: 'minutes' }, 97 | hour: { message: 'hour' }, 98 | hours: { message: 'hours' }, 99 | day: { message: 'day' }, 100 | week: { message: 'week' }, 101 | month: { message: 'month' }, 102 | always: { message: 'Always' }, 103 | 104 | // Chart page 105 | operationBar: { message: 'Operation Bar' }, 106 | drapToLookOtherUsers: { message: 'Drag slider to look other users.' }, 107 | showDetail: { message: 'Show Detail' }, 108 | showTotal: { message: 'Show Total' }, 109 | showMessage: { message: 'Show Messages' }, 110 | showCharacter: { message: 'Show Characters' }, 111 | detail: { message: 'Detail' }, 112 | total: { message: 'Total' }, 113 | message: { message: 'Messages' }, 114 | character: { message: 'Characters' }, 115 | me: { message: 'Me' }, 116 | other: { message: 'Other' }, 117 | 118 | // Sharing dialog 119 | countPrefix: { message: 'They have' }, 120 | countPostfix: { message: 'messages!!' }, 121 | rank: { 122 | message: '$friend$ is #$rank$ of $self$\'s friends', 123 | placeholders: { self: { content: '$1' }, rank: { content: '$2' }, friend: { content: '$3' } } 124 | }, 125 | generatedByPrefix: { message: 'generated by' }, 126 | generatedByPostfix: { message: '' }, 127 | download: { message: 'Download' }, 128 | 129 | // Error message 130 | fetchError: { message: 'Oops, cannot fetch messages.' }, 131 | contactDevelper: { message: 'Please contact developer.' }, 132 | loginRequired: { message: 'Please login Facebook first.' }, 133 | 134 | // Symbol 135 | comma: { message: ', ' }, 136 | colon: { message: ': ' }, 137 | 138 | // Thread Object 139 | others: { message: '$1 Others' }, 140 | 141 | // ALert message of content script 142 | loginAlert: { message: 'Please log in to messenger to count your messages.' } 143 | } 144 | -------------------------------------------------------------------------------- /src/tab/root.vue: -------------------------------------------------------------------------------- 1 | 98 | 99 | 125 | 126 | 177 | 178 | 256 | -------------------------------------------------------------------------------- /src/tab/index.js: -------------------------------------------------------------------------------- 1 | // Vue.js 2 | import Vue from 'vue' 3 | // Google analytics 4 | import VueAnalytics from 'vue-analytics' 5 | // Import element-ui components. 6 | import { 7 | Slider, 8 | Loading, 9 | Button, 10 | Table, 11 | TableColumn, 12 | Tag, 13 | Tooltip, 14 | DatePicker, 15 | Pagination, 16 | Switch, 17 | Container, 18 | Menu, 19 | MenuItem, 20 | Header, 21 | Aside, 22 | Main, 23 | Footer, 24 | MessageBox, 25 | Input, 26 | Form, 27 | FormItem, 28 | Dialog 29 | } from 'element-ui' 30 | // Customize element-ui theme. Let this style more like FB. 31 | import '../../element-variables.scss' 32 | /** 33 | * Languages of Element-UI 34 | * Base on locales of this extension. 35 | */ 36 | import enLocale from 'element-ui/lib/locale/lang/en' 37 | import zhLocale from 'element-ui/lib/locale/lang/zh-TW' 38 | 39 | // Libs 40 | import locale from 'element-ui/lib/locale' 41 | import _get from 'lodash/get' 42 | import Root from './Root.vue' 43 | import router from './router' 44 | import fetchThreads from './lib/fetchThreads.js' 45 | import { getJar } from './lib/util.js' 46 | import Indexeddb from '../ext/Indexeddb.js' 47 | import storage from '../ext/storage.js' 48 | import Queue from 'promise-queue' 49 | import SocialSharing from 'vue-social-sharing' 50 | import Icon from 'vue-awesome/components/Icon' 51 | 52 | const _queue = new Queue(10, Infinity) 53 | 54 | // Alias i18n function. 55 | const __ = chrome.i18n.getMessage 56 | Vue.prototype.__ = __ 57 | 58 | // Change docuemnt title manually. Coz title is assign on build stage. 59 | document.title = __('extName') 60 | 61 | Vue.config.productionTip = false 62 | 63 | const mainLangName = chrome.i18n.getUILanguage().split('-')[0] 64 | locale.use((mainLangName === 'zh') ? zhLocale : enLocale) 65 | 66 | // Import element-ui components. 67 | const elements = [ 68 | Slider, 69 | Loading, 70 | Button, 71 | Table, 72 | TableColumn, 73 | Tag, 74 | Tooltip, 75 | DatePicker, 76 | Pagination, 77 | Switch, 78 | Container, 79 | Menu, 80 | MenuItem, 81 | Header, 82 | Aside, 83 | Main, 84 | Footer, 85 | Input, 86 | Form, 87 | FormItem, 88 | Dialog 89 | ] 90 | elements.forEach((el) => Vue.use(el, { locale })) 91 | 92 | const isInProduction = process.env.NODE_ENV === 'production' 93 | Vue.use(VueAnalytics, { 94 | id: (isInProduction && !process.env.BETA) ? 'UA-114347247-1' : 'UA-114347247-3', 95 | // In Chrome extension, must close checking protocol. 96 | set: [{ field: 'checkProtocolTask', value: null }], 97 | router, 98 | autoTracking: { exception: true }, 99 | debug: { 100 | enabled: !isInProduction, 101 | sendHitTask: isInProduction 102 | } 103 | }) 104 | 105 | Vue.component('icon', Icon) 106 | Vue.use(SocialSharing) 107 | 108 | Vue.prototype.$message = MessageBox 109 | Vue.prototype.$alert = MessageBox.alert 110 | Vue.prototype.$confirm = MessageBox.confirm 111 | Vue.prototype.$prompt = MessageBox.prompt 112 | 113 | // copy from https://github.com/GoogleChrome/chrome-app-samples/blob/master/samples/managed-in-app-payments/scripts/buy.js 114 | !(function() { var f=this,g=function(a,d){var c=a.split("."),b=window||f;c[0]in b||!b.execScript||b.execScript("var "+c[0]);for(var e;c.length&&(e=c.shift());)c.length||void 0===d?b=b[e]?b[e]:b[e]={}:b[e]=d};var h=function(a){var d=chrome.runtime.connect("nmmhkkegccagdldgiimedpiccmgmieda",{}),c=!1;d.onMessage.addListener(function(b){c=!0;"response"in b&&!("errorType"in b.response)?a.success&&a.success(b):a.failure&&a.failure(b)});d.onDisconnect.addListener(function(){!c&&a.failure&&a.failure({request:{},response:{errorType:"INTERNAL_SERVER_ERROR"}})});d.postMessage(a)};g("google.payments.inapp.buy",function(a){a.method="buy";h(a)}); // eslint-disable-line 115 | g("google.payments.inapp.consumePurchase",function(a){a.method="consumePurchase";h(a)});g("google.payments.inapp.getPurchases",function(a){a.method="getPurchases";h(a)});g("google.payments.inapp.getSkuDetails",function(a){a.method="getSkuDetails";h(a)}); })(); // eslint-disable-line 116 | // End copy 117 | 118 | /* eslint-disable no-new */ 119 | new Vue({ 120 | el: '#root', 121 | 122 | router, 123 | 124 | components: { Root }, 125 | 126 | render (h) { return }, 127 | 128 | data () { 129 | return { 130 | loading: null, 131 | ctx: { 132 | threads: [], 133 | jar: null, 134 | db: null 135 | }, 136 | iSee: storage.get('iSee', false) 137 | } 138 | }, 139 | 140 | watch: { 141 | iSee (value) { 142 | storage.set('iSee', value) 143 | } 144 | }, 145 | 146 | async created () { 147 | this.loading = this.$loading({ 148 | lock: true, 149 | text: this.__('interceptingToken'), 150 | spinner: 'el-icon-loading', 151 | background: 'rgba(0, 0, 0, 0.7)' 152 | }) 153 | 154 | // Let user know fetching data maybe take a long time. 155 | if (!this.iSee) { 156 | this.$confirm(__('openingAlertContent'), __('openingAlertTitle'), { 157 | confirmButtonText: __('iSee'), 158 | showCancelButton: true, 159 | cancelButtonText: __('cancel'), 160 | center: true 161 | }).then(() => (this.iSee = true), () => (this.iSee = false)) 162 | } 163 | 164 | // get information about user buy any premium productions. 165 | try { 166 | const getPurchasesResult = await new Promise(function (resolve, reject) { 167 | window.google.payments.inapp.getPurchases({ 168 | parameters: { env: 'prod' }, 169 | success: resolve, 170 | failure: reject 171 | }) 172 | }) 173 | this.ctx.purchased = !!getPurchasesResult.response.details 174 | .find((detail) => detail.sku === 'premium' && detail.state === 'ACTIVE') 175 | } catch (err) { 176 | console.error(err) 177 | } 178 | 179 | // extract token and user id from facebook page. 180 | const createJar = async () => { 181 | const jar = await getJar() 182 | this.ctx.jar = jar 183 | this.ctx.db = new Indexeddb(jar.selfId) 184 | } 185 | try { 186 | await createJar() 187 | } catch (err) { 188 | this.$ga.event('Login', 'need') 189 | 190 | // not login yet 191 | this.loading.text = __('loginRequired') 192 | try { 193 | await this.$alert(__('loginRequired'), __('loginRequired'), { 194 | type: 'warning' 195 | }) 196 | } catch (err) {} 197 | 198 | // assist user to login 199 | let retryCount = 0 200 | const appTabId = (await new Promise((resolve, reject) => chrome.tabs.getCurrent(resolve))).id 201 | 202 | await new Promise((resolve, reject) => { 203 | const onMessage = (request, sender, sendResponse) => { 204 | chrome.tabs.update(appTabId, { active: true }) 205 | const retry = async () => { 206 | try { 207 | await createJar() 208 | chrome.runtime.onMessage.removeListener(onMessage) 209 | this.$ga.event('Login', 'success') 210 | return resolve() 211 | } catch (err) { 212 | if (retryCount > 50) { 213 | this.$ga.event('Login', 'detect-failed') 214 | this.$alert(__('cannotDetectLoginContent'), __('error'), { 215 | confirmButtonText: __('refresh'), 216 | type: 'warning' 217 | }).then(() => document.location.reload(), () => {}) 218 | throw new Error('Cannot detect facebook login.') 219 | } 220 | retryCount += 1 221 | // take a while to wait "log in" facebook successful. 222 | return setTimeout(() => retry(), 133) 223 | } 224 | } 225 | return retry() 226 | } 227 | 228 | // setup listener to listen submit event on login facebook 229 | chrome.runtime.onMessage.addListener(onMessage) 230 | 231 | // create a new facebook page 232 | chrome.tabs.create({ url: 'https://www.facebook.com/', active: true }) 233 | 234 | console.warn(err) 235 | }) 236 | } 237 | 238 | // fetch threads information 239 | this.loading.text = __('fetchingThreads') 240 | await new Promise((resolve, reject) => { 241 | this.ctx.db.onload = async () => { 242 | try { 243 | const [threads, cachedThreads] = await Promise.all([ 244 | fetchThreads(this.ctx.jar), 245 | this.ctx.db.getAll() 246 | ]) 247 | threads.forEach((thread) => { 248 | const mappingCacheThread = cachedThreads.find((cachedThread) => 249 | cachedThread.id === thread.id) 250 | if (mappingCacheThread) { 251 | thread.analyzeMessages(mappingCacheThread.messages) 252 | } 253 | thread.needUpdate = (thread.messageCount !== _get(mappingCacheThread, 'messages.length')) 254 | }) 255 | this.ctx.threads = threads 256 | this.loading.close() 257 | return resolve() 258 | } catch (err) { 259 | return reject(err) 260 | } 261 | } 262 | }) 263 | 264 | this.ctx.threads.forEach((thread) => _queue.add(thread.loadDetail.bind(thread, this.ctx, this.$set))) 265 | } 266 | }) 267 | -------------------------------------------------------------------------------- /src/tab/components/ChartPage.vue: -------------------------------------------------------------------------------- 1 | 63 | 290 | 291 | 305 | -------------------------------------------------------------------------------- /src/tab/components/ThreadList/index.vue: -------------------------------------------------------------------------------- 1 | 174 | 175 | 365 | 366 | 381 | 382 | 408 | -------------------------------------------------------------------------------- /src/tab/lib/fetchThreadDetail.js: -------------------------------------------------------------------------------- 1 | /// ////////////////////////////////////////////////////////////////////////////// 2 | // Fetch thread detail information (include messages). And parse every message. // 3 | /// ////////////////////////////////////////////////////////////////////////////// 4 | import _compact from 'lodash/compact' 5 | import { graphql, getQraphqlForm } from './util' 6 | 7 | function handleFetch ({ jar, senderID, messageCount, messageLimit = 7500, before = null }) { 8 | messageLimit = Math.floor(Math.min(messageLimit, messageCount)) 9 | 10 | // Prepare request form body. 11 | const queries = { 12 | o0: { 13 | doc_id: '1583545408361109', // I'm not sure what is it. 14 | query_params: { 15 | id: senderID, // thread id 16 | message_limit: messageLimit, // limit of fetching messages 17 | load_messages: 1, 18 | load_read_receipts: false, 19 | before // offset timestamp 20 | } 21 | } 22 | } 23 | const form = getQraphqlForm({ queries }, jar) 24 | return graphql('https://www.facebook.com/api/graphqlbatch/', form) 25 | } 26 | 27 | // Shamely copy from https://github.com/KevinSalmon/facebook-chat-api/blob/6cb19d038a35c92dc8ac6d6250c0ed34981e86ea/src/getThreadHistoryGraphQL.js 28 | function formatAttachmentsGraphQLResponse (attachment) { 29 | switch (attachment.__typename) { 30 | case 'MessageImage': { 31 | return { 32 | // You have to query for the real image. See below. 33 | url: '', 34 | width: 0, 35 | height: 0, 36 | 37 | // Both gifs and images have this type now. Just to be consistent with 38 | // FB, and there doesn't seem to be many drawbacks. 39 | type: 'image', 40 | filename: attachment.filename, 41 | attachmentID: attachment.legacy_attachment_id, 42 | previewHeight: attachment.preview.height, 43 | previewUrl: attachment.preview.uri, 44 | previewWidth: attachment.preview.width, 45 | thumbnailUrl: attachment.thumbnail.uri, 46 | 47 | attributionApp: attachment.attribution_app ? { 48 | attributionAppID: attachment.attribution_app.id, 49 | name: attachment.attribution_app.name, 50 | logo: attachment.attribution_app.square_logo 51 | } : null, 52 | 53 | extension: attachment.original_extension, 54 | 55 | // @TODO No idea what this is, should we expose it? 56 | // Ben - July 15th 2017 57 | // renderAsSticker: attachment.render_as_sticker, 58 | 59 | // This is _not_ the real URI, this is still just a large preview. 60 | // To get the URL we'll need to support a POST query to 61 | // 62 | // https://www.facebook.com/webgraphql/query/ 63 | // 64 | // With the following query params: 65 | // 66 | // query_id:728987990612546 67 | // variables:{"id":"100009069356507","photoID":"10213724771692996"} 68 | // dpr:1 69 | // 70 | // No special form though. 71 | largePreviewUrl: attachment.large_preview.uri, 72 | largePreviewHeight: attachment.large_preview.height, 73 | largePreviewWidth: attachment.large_preview.width 74 | } 75 | } 76 | case 'MessageAnimatedImage': 77 | return { 78 | type: 'image', 79 | filename: attachment.filename, 80 | attachmentID: attachment.legacy_attachment_id, 81 | previewHeight: attachment.preview_image.height, 82 | previewUrl: attachment.preview_image.uri, 83 | previewWidth: attachment.preview_image.width, 84 | largePreviewUrl: attachment.animated_image.uri, 85 | largePreviewHeight: attachment.animated_image.height, 86 | largePreviewWidth: attachment.animated_image.width, 87 | 88 | attributionApp: attachment.attribution_app ? { 89 | attributionAppID: attachment.attribution_app.id, 90 | name: attachment.attribution_app.name, 91 | logo: attachment.attribution_app.square_logo 92 | } : null 93 | } 94 | case 'MessageVideo': 95 | return { 96 | // Deprecated fields. 97 | previewHeight: '', 98 | previewUrl: '', 99 | previewWidth: '', 100 | 101 | type: 'video', 102 | thumbnailUrl: attachment.large_image.uri, 103 | filename: attachment.filename, 104 | height: attachment.original_dimensions.y, 105 | width: attachment.original_dimensions.x, 106 | attachmentID: attachment.legacy_attachment_id, 107 | url: attachment.playable_url, 108 | 109 | duration: attachment.playable_duration_in_ms, 110 | thumbnailWidth: attachment.large_image.width, 111 | thumbnailHeight: attachment.large_image.height, 112 | // Not sure what this is. 113 | // Ben - July 15th 2017 114 | videoType: attachment.video_type.toLowerCase() 115 | } 116 | case 'MessageFile': 117 | return { 118 | attachmentID: attachment.url_shimhash, // Should be good enough as an ID 119 | isMalicious: attachment.is_malicious, 120 | type: 'file', 121 | url: attachment.url, 122 | 123 | contentType: attachment.content_type, 124 | filename: attachment.filename 125 | } 126 | case 'MessageAudio': 127 | return { 128 | attachmentID: attachment.url_shimhash, // Copied from above 129 | 130 | type: 'audio', 131 | audioType: attachment.audio_type, 132 | duration: attachment.playable_duration_in_ms, 133 | url: attachment.playable_url, 134 | 135 | isVoiceMail: attachment.is_voicemail, 136 | filename: attachment.filename 137 | } 138 | default: 139 | console.log(attachment) 140 | return console.error('Don\'t know about attachment type ' + attachment.__typename) 141 | } 142 | } 143 | 144 | function formatEventData (event) { 145 | if (event == null) { 146 | return {} 147 | } 148 | 149 | switch (event.__typename) { 150 | case 'ThemeColorExtensibleMessageAdminText': 151 | return { 152 | color: event.theme_color 153 | } 154 | case 'ThreadNicknameExtensibleMessageAdminText': 155 | return { 156 | nickname: event.nickname, 157 | participantID: event.participant_id 158 | } 159 | case 'ThreadIconExtensibleMessageAdminText': 160 | return { 161 | threadIcon: event.thread_icon 162 | } 163 | case 'InstantGameUpdateExtensibleMessageAdminText': 164 | return { 165 | gameID: (event.game) ? event.game.id : null, 166 | update_type: event.update_type, 167 | collapsed_text: event.collapsed_text, 168 | expanded_text: event.expanded_text, 169 | instant_game_update_data: event.instant_game_update_data 170 | } 171 | case 'GameScoreExtensibleMessageAdminText': 172 | return { 173 | game_type: event.game_type 174 | } 175 | case 'RtcCallLogExtensibleMessageAdminText': 176 | return { 177 | event: event.event, 178 | is_video_call: event.is_video_call, 179 | server_info_data: event.server_info_data 180 | } 181 | case 'GroupPollExtensibleMessageAdminText': 182 | return { 183 | event_type: event.event_type, 184 | total_count: event.total_count, 185 | question: event.question 186 | } 187 | case 'AcceptPendingThreadExtensibleMessageAdminText': 188 | return { 189 | accepter_id: event.accepter_id, 190 | requester_id: event.requester_id 191 | } 192 | case 'ConfirmFriendRequestExtensibleMessageAdminText': 193 | return { 194 | friend_request_recipient: event.friend_request_recipient, 195 | friend_request_sender: event.friend_request_sender 196 | } 197 | case 'AddContactExtensibleMessageAdminText': 198 | return { 199 | contact_added_id: event.contact_added_id, 200 | contact_adder_id: event.contact_adder_id 201 | } 202 | case 'AdExtensibleMessageAdminText': 203 | return { 204 | ad_client_token: event.ad_client_token, 205 | ad_id: event.ad_id, 206 | ad_preferences_link: event.ad_preferences_link, 207 | ad_properties: event.ad_properties 208 | } 209 | // never data 210 | case 'ParticipantJoinedGroupCallExtensibleMessageAdminText': 211 | case 'ThreadEphemeralTtlModeExtensibleMessageAdminText': 212 | case 'StartedSharingVideoExtensibleMessageAdminText': 213 | case 'LightweightEventCreateExtensibleMessageAdminText': 214 | case 'LightweightEventNotifyExtensibleMessageAdminText': 215 | case 'LightweightEventNotifyBeforeEventExtensibleMessageAdminText': 216 | case 'LightweightEventUpdateExtensibleMessageAdminText': 217 | case 'LightweightEventUpdateTitleExtensibleMessageAdminText': 218 | case 'LightweightEventUpdateTimeExtensibleMessageAdminText': 219 | case 'LightweightEventUpdateLocationExtensibleMessageAdminText': 220 | case 'LightweightEventDeleteExtensibleMessageAdminText': 221 | return {} 222 | default: 223 | console.log(event) 224 | return console.error('Don\'t know what to with event data type ' + event.__typename) 225 | } 226 | } 227 | 228 | function formatExtensibleAttachment (attachment) { 229 | if (attachment.story_attachment) { 230 | return { 231 | type: 'share', 232 | description: (attachment.story_attachment.description == null) ? null : attachment.story_attachment.description.text, 233 | attachmentID: attachment.legacy_attachment_id, 234 | title: attachment.story_attachment.title_with_entities.text, 235 | subattachments: attachment.story_attachment.subattachments, 236 | url: attachment.story_attachment.url, 237 | source: (attachment.story_attachment.source == null) ? null : attachment.story_attachment.source.text, 238 | playable: (attachment.story_attachment.media == null) ? null : attachment.story_attachment.media.is_playable, 239 | 240 | // New 241 | thumbnailUrl: (attachment.story_attachment.media == null) ? null : (attachment.story_attachment.media.animated_image == null && attachment.story_attachment.media.image == null) ? null : (attachment.story_attachment.media.animated_image || attachment.story_attachment.media.image).uri, 242 | thumbnailWidth: (attachment.story_attachment.media == null) ? null : (attachment.story_attachment.media.animated_image == null && attachment.story_attachment.media.image == null) ? null : (attachment.story_attachment.media.animated_image || attachment.story_attachment.media.image).width, 243 | thumbnailHeight: (attachment.story_attachment.media == null) ? null : (attachment.story_attachment.media.animated_image == null && attachment.story_attachment.media.image == null) ? null : (attachment.story_attachment.media.animated_image || attachment.story_attachment.media.image).height, 244 | duration: (attachment.story_attachment.media == null) ? null : attachment.story_attachment.media.playable_duration_in_ms, 245 | playableUrl: (attachment.story_attachment.media == null) ? null : attachment.story_attachment.media.playable_url, 246 | 247 | // Format example: 248 | // 249 | // [{ 250 | // key: "width", 251 | // value: { text: "1280" } 252 | // }] 253 | // 254 | // That we turn into: 255 | // 256 | // { 257 | // width: "1280" 258 | // } 259 | // 260 | properties: attachment.story_attachment.properties.reduce(function (obj, cur) { 261 | obj[cur.key] = cur.value.text 262 | return obj 263 | }, {}) 264 | } 265 | } else { 266 | return console.error('Don\'t know what to do with extensible_attachment.') 267 | } 268 | } 269 | 270 | function formatReactionsGraphQL (reaction) { 271 | return { 272 | reaction: reaction.reaction, 273 | userID: reaction.user.id 274 | } 275 | } 276 | 277 | function formatMessagesGraphQLResponse (messageThread) { 278 | const threadID = messageThread.thread_key.thread_fbid ? messageThread.thread_key.thread_fbid : messageThread.thread_key.other_user_id 279 | 280 | const messages = messageThread.messages.nodes.map(function (d) { 281 | switch (d.__typename) { 282 | case 'UserMessage': { 283 | // Give priority to stickers. They're seen as normal messages but we've 284 | // been considering them as attachments. 285 | let maybeStickerAttachment 286 | if (d.sticker) { 287 | maybeStickerAttachment = [{ 288 | caption: d.snippet, // Not sure what the heck caption was. 289 | description: d.sticker.label, // Not sure about this one either. 290 | frameCount: d.sticker.frame_count, 291 | frameRate: d.sticker.frame_rate, 292 | framesPerCol: d.sticker.frames_per_col, 293 | framesPerRow: d.sticker.frames_per_row, 294 | packID: d.sticker.pack.id, 295 | spriteURI2x: d.sticker.sprite_image_2x, 296 | spriteURI: d.sticker.sprite_image, 297 | stickerID: d.sticker.id, 298 | url: d.sticker.url, // Oh yeah thanks, sometimes it's URI sometimes it's URL. 299 | height: d.sticker.height, 300 | width: d.sticker.width, 301 | type: 'sticker' 302 | }] 303 | } 304 | 305 | return { 306 | type: 'message', 307 | attachments: maybeStickerAttachment || ((d.blob_attachments && d.blob_attachments.length > 0) ? _compact(d.blob_attachments.map(formatAttachmentsGraphQLResponse)) 308 | : (d.extensible_attachment) ? _compact(formatExtensibleAttachment(d.extensible_attachment)) 309 | : []), 310 | body: d.message.text, 311 | // threadType: messageThread.thread_type, 312 | messageID: d.message_id, 313 | senderID: d.message_sender.id, 314 | // threadID: threadID, 315 | 316 | // New 317 | messageReactions: d.message_reactions ? _compact(d.message_reactions.map(formatReactionsGraphQL)) : null, 318 | isSponsered: d.is_sponsored, 319 | snippet: d.snippet, 320 | timestamp: d.timestamp_precise 321 | } 322 | } 323 | case 'ThreadNameMessage': 324 | return { 325 | type: 'event', 326 | messageID: d.message_id, 327 | // threadID: threadID, 328 | // Can be either "GROUP" or ONE_TO_ONE. 329 | // threadType: messageThread.thread_type, 330 | senderID: d.message_sender.id, 331 | timestamp: d.timestamp_precise, 332 | eventType: 'change_thread_name', 333 | snippet: d.snippet, 334 | eventData: { 335 | threadName: d.thread_name 336 | } 337 | } 338 | case 'ThreadImageMessage': 339 | return { 340 | type: 'event', 341 | messageID: d.message_id, 342 | threadID: threadID, 343 | // Can be either "GROUP" or ONE_TO_ONE. 344 | // threadType: messageThread.thread_type, 345 | senderID: d.message_sender.id, 346 | timestamp: d.timestamp_precise, 347 | eventType: 'change_thread_image', 348 | snippet: d.snippet, 349 | eventData: (d.image_with_metadata == null) ? {} /* removed image */ : { /* image added */ 350 | threadImage: { 351 | attachmentID: d.image_with_metadata.legacy_attachment_id, 352 | height: d.image_with_metadata.original_dimensions.x, 353 | width: d.image_with_metadata.original_dimensions.y, 354 | url: d.image_with_metadata.preview.uri 355 | } 356 | } 357 | } 358 | case 'ParticipantLeftMessage': 359 | return { 360 | type: 'event', 361 | messageID: d.message_id, 362 | threadID: threadID, 363 | // Can be either "GROUP" or ONE_TO_ONE. 364 | // threadType: messageThread.thread_type, 365 | senderID: d.message_sender.id, 366 | timestamp: d.timestamp_precise, 367 | eventType: 'remove_participants', 368 | snippet: d.snippet, 369 | eventData: { 370 | // Array of IDs. 371 | participantsRemoved: d.participants_removed.map(function (p) { return p.id }) 372 | } 373 | } 374 | case 'ParticipantsAddedMessage': 375 | return { 376 | type: 'event', 377 | messageID: d.message_id, 378 | threadID: threadID, 379 | // Can be either "GROUP" or ONE_TO_ONE. 380 | // threadType: messageThread.thread_type, 381 | senderID: d.message_sender.id, 382 | timestamp: d.timestamp_precise, 383 | eventType: 'add_participants', 384 | snippet: d.snippet, 385 | eventData: { 386 | // Array of IDs. 387 | participantsAdded: d.participants_added.map(function (p) { return p.id }) 388 | } 389 | } 390 | case 'VideoCallMessage': 391 | return { 392 | type: 'event', 393 | messageID: d.message_id, 394 | threadID: threadID, 395 | // Can be either "GROUP" or ONE_TO_ONE. 396 | // threadType: messageThread.thread_type, 397 | senderID: d.message_sender.id, 398 | timestamp: d.timestamp_precise, 399 | eventType: 'video_call', 400 | snippet: d.snippet 401 | } 402 | case 'VoiceCallMessage': 403 | return { 404 | type: 'event', 405 | messageID: d.message_id, 406 | threadID: threadID, 407 | // Can be either "GROUP" or ONE_TO_ONE. 408 | // threadType: messageThread.thread_type, 409 | senderID: d.message_sender.id, 410 | timestamp: d.timestamp_precise, 411 | eventType: 'voice_call', 412 | snippet: d.snippet 413 | } 414 | case 'GenericAdminTextMessage': 415 | return { 416 | type: 'event', 417 | messageID: d.message_id, 418 | threadID: threadID, 419 | // Can be either "GROUP" or ONE_TO_ONE. 420 | // threadType: messageThread.thread_type, 421 | senderID: d.message_sender.id, 422 | timestamp: d.timestamp_precise, 423 | snippet: d.snippet, 424 | eventType: d.extensible_message_admin_text_type.toLowerCase(), 425 | eventData: (d.extensible_message_admin_text) ? formatEventData(d.extensible_message_admin_text) : null 426 | } 427 | default: 428 | console.log(d) 429 | return console.error('Don\'t know about message type ' + d.__typename) 430 | } 431 | }) 432 | return messages 433 | } 434 | // End shamely copy. 435 | 436 | async function fetchThreadDetail ({ jar, senderID, messageCount, messageLimit = 7500, before = null }) { 437 | const messageThread = await handleFetch({ jar, senderID, messageCount, messageLimit, before }) 438 | .then(async (json) => { 439 | const messageThread = json.o0.data.message_thread 440 | if (!messageThread.messages.page_info) { 441 | throw new Error('No page_info.') 442 | } 443 | return messageThread 444 | }) 445 | .catch((err) => { 446 | console.error(err) 447 | messageLimit = messageLimit / 2 448 | if (messageLimit > 1000) { 449 | return handleFetch({ 450 | jar, senderID, messageCount, messageLimit, before 451 | }) 452 | } else { throw new Error('Too many error on fetch.') } 453 | }) 454 | 455 | const messages = _compact(formatMessagesGraphQLResponse(messageThread)) 456 | messageCount = messageCount - messages.length 457 | if (messageThread.messages.page_info.has_previous_page && messages[0] && messageCount) { 458 | return (await fetchThreadDetail({ 459 | jar, 460 | senderID, 461 | messageCount, 462 | messageLimit, 463 | before: messages[0].timestamp 464 | })).concat(messages) 465 | } else { 466 | return messages 467 | } 468 | } 469 | 470 | export default async function (args) { 471 | const { thread, messageLimit, before, jar } = args 472 | 473 | // Copy thread and use it in fetch handler. 474 | const senderID = thread.id 475 | const messageCount = (messageLimit) ? Math.min(thread.messageCount, messageLimit) : thread.messageCount 476 | // Fetch thread detail information. 477 | return fetchThreadDetail({ 478 | jar, senderID, messageCount, messageLimit, before 479 | }) 480 | } 481 | --------------------------------------------------------------------------------