├── .codeclimate.yml ├── .dockerignore ├── .editorconfig ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── nodejs.yml │ └── reg.yml ├── .gitignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── __mocks__ ├── bootstrap.native.ts └── styleMock.ts ├── __tests__ ├── assets │ └── ts │ │ ├── oembed.spec.ts │ │ └── util.spec.ts ├── axios-mock.ts ├── components │ ├── ActionButton.spec.ts │ ├── AppDropdown.ts │ ├── Avatar.spec.ts │ ├── BlockButton.spec.ts │ ├── ChatPanel.spec.ts │ ├── Compose.spec.ts │ ├── CustomCheckbox.spec.ts │ ├── EntityText.spec.ts │ ├── FollowButton.spec.ts │ ├── Header.spec.ts │ ├── InputLongost.spec.ts │ ├── InputLongpost.spec.ts │ ├── Interaction.spec.ts │ ├── List.spec.ts │ ├── MessageCompose.spec.ts │ ├── Nsfw.spec.ts │ ├── NuxtLinkMod.spec.ts │ ├── Poll.spec.ts │ ├── Post.spec.ts │ ├── PostList.spec.ts │ ├── PostModal.spec.ts │ ├── Profile.spec.ts │ ├── RemoveModal.spec.ts │ ├── SearchForm.spec.ts │ ├── Splash.spec.ts │ ├── ToggleLongpost.spec.ts │ ├── ToggleNsfw.spec.ts │ ├── ToggleSpoiler.spec.ts │ ├── User.spec.ts │ ├── atoms │ │ ├── MuteButton.spec.ts │ │ ├── RelationBadge.spec.ts │ │ └── TogglePoll.spec.ts │ ├── molecules │ │ ├── EmojiPicker.ts │ │ ├── FileRow.spec.ts │ │ └── Sound.spec.ts │ ├── organisms │ │ └── LongPost.ts │ ├── settings │ │ └── Account │ │ │ ├── Avatar.spec.ts │ │ │ ├── Cover.spec.ts │ │ │ └── index.spec.ts │ └── sidebar │ │ └── App.spec.ts ├── fixtures │ ├── channel.ts │ ├── client.ts │ ├── file.ts │ ├── index.ts │ ├── interaction.ts │ ├── poll.ts │ ├── post.ts │ └── user.ts ├── helper.ts ├── setup.ts └── setupAfter.ts ├── _redirects ├── example.env ├── jest.config.js ├── nuxt.config.ts ├── package.json ├── regconfig.json ├── renovate.json ├── src ├── assets │ ├── css │ │ ├── _mixin.scss │ │ ├── _override.scss │ │ ├── adn_base_variables.scss │ │ └── main.scss │ ├── img │ │ └── beta.svg │ ├── json │ │ ├── locales.json │ │ └── timezones.json │ └── ts │ │ ├── actionable.ts │ │ ├── bus.ts │ │ ├── key-binding │ │ ├── default-mixin.ts │ │ ├── for-list.ts │ │ └── index.ts │ │ ├── list-item.ts │ │ ├── notification-wrapper.ts │ │ ├── oembed.ts │ │ ├── refresh-after-added.ts │ │ ├── resettable.ts │ │ ├── search.ts │ │ ├── text-count.ts │ │ └── util.ts ├── components │ ├── atoms │ │ ├── AclSelect.stories.ts │ │ ├── AclSelect.vue │ │ ├── ActionButton.vue │ │ ├── AddFile.vue │ │ ├── Avatar.stories.ts │ │ ├── Avatar.vue │ │ ├── BlockButton.vue │ │ ├── CustomCheckbox.stories.ts │ │ ├── CustomCheckbox.vue │ │ ├── EmojiButton.vue │ │ ├── EntityText.stories.ts │ │ ├── EntityText.vue │ │ ├── FollowButton.stories.ts │ │ ├── FollowButton.vue │ │ ├── MuteButton.stories.ts │ │ ├── MuteButton.vue │ │ ├── NuxtLinkMod.vue │ │ ├── PageTitle.vue │ │ ├── RelationBadge.stories.ts │ │ ├── RelationBadge.vue │ │ ├── SourceLink.vue │ │ ├── ThumbnailImage.vue │ │ ├── ToggleButton.stories.ts │ │ ├── ToggleButton.vue │ │ ├── ToggleLongpost.vue │ │ ├── ToggleNsfw.vue │ │ ├── TogglePoll.vue │ │ └── ToggleSpoiler.vue │ ├── layouts │ │ └── channel.vue │ ├── molecules │ │ ├── AppDropdown.vue │ │ ├── BaseChannelPanel.ts │ │ ├── BaseList.vue │ │ ├── BaseModal.vue │ │ ├── Channel.vue │ │ ├── EmojiPicker.vue │ │ ├── FilePreview │ │ │ ├── FilePreview.vue │ │ │ ├── FilePreviewAbstract.ts │ │ │ ├── FilePreviewAudio.vue │ │ │ ├── FilePreviewImage.vue │ │ │ └── FilePreviewVideo.vue │ │ ├── FileRow.stories.ts │ │ ├── FileRow.vue │ │ ├── KeySets.vue │ │ ├── SearchForm.vue │ │ ├── Sound.vue │ │ ├── Splash.vue │ │ ├── Thumb.vue │ │ ├── User.vue │ │ ├── UserPopper.vue │ │ ├── UserPopperInner.stories.ts │ │ ├── UserPopperInner.vue │ │ └── settings │ │ │ ├── Account │ │ │ ├── Avatar.vue │ │ │ ├── Cover.vue │ │ │ └── index.vue │ │ │ ├── Display.vue │ │ │ ├── Notifications.vue │ │ │ └── Stream.vue │ └── organisms │ │ ├── ChannelCompose.vue │ │ ├── ChannelEditModal.vue │ │ ├── ChannelList.vue │ │ ├── ChannelMemberEditModal.vue │ │ ├── ChannelPanel.vue │ │ ├── ChannelUserList.vue │ │ ├── ChatPanel.vue │ │ ├── Compose.stories.ts │ │ ├── Compose.vue │ │ ├── ComposeAbstract.ts │ │ ├── CreatePmModal.vue │ │ ├── FilePreviewList.vue │ │ ├── FileView.vue │ │ ├── Header.stories.ts │ │ ├── Header.vue │ │ ├── HelpModal.stories.ts │ │ ├── HelpModal.vue │ │ ├── InputLongpost.vue │ │ ├── InputPoll.vue │ │ ├── InputSpoiler.vue │ │ ├── Interaction.vue │ │ ├── InteractionList.vue │ │ ├── LongPost.vue │ │ ├── Message.vue │ │ ├── MessageCompose.vue │ │ ├── MessageIcon.vue │ │ ├── MessageList.vue │ │ ├── MessageModal.vue │ │ ├── MessageRemoveModal.vue │ │ ├── Nsfw.vue │ │ ├── PmPanel.vue │ │ ├── Poll.vue │ │ ├── PollList.vue │ │ ├── PollNotice.vue │ │ ├── Post.vue │ │ ├── PostList.vue │ │ ├── PostModal.vue │ │ ├── Profile.stories.ts │ │ ├── Profile.vue │ │ ├── RemoveModal.vue │ │ ├── SuggestUsers.vue │ │ ├── UserList.vue │ │ ├── file-list.vue │ │ └── sidebar │ │ ├── About.ts │ │ ├── App.ts │ │ ├── Files.ts │ │ ├── MenuItem.ts │ │ ├── Search.ts │ │ ├── Settings.ts │ │ └── Sidebar.vue ├── entity │ ├── Unread.ts │ ├── channel.ts │ ├── client.ts │ ├── config.ts │ ├── connection.ts │ ├── entity.ts │ ├── file.ts │ ├── interaction.ts │ ├── marker.ts │ ├── message.ts │ ├── pageable.ts │ ├── pnut-response.ts │ ├── poll.ts │ ├── post.ts │ ├── raw.ts │ ├── raw │ │ ├── raw │ │ │ ├── broadcast-notice.ts │ │ │ ├── channel-avatar-image.ts │ │ │ ├── channel-cover-image.ts │ │ │ ├── channel-invite.ts │ │ │ ├── chat-room-settings.ts │ │ │ ├── crosspost.ts │ │ │ ├── embedded-media.ts │ │ │ ├── external-user-profiles.ts │ │ │ ├── fallback-url.ts │ │ │ ├── language.ts │ │ │ ├── live-photo.ts │ │ │ ├── long-post.ts │ │ │ ├── oembed.ts │ │ │ ├── poll-notice.ts │ │ │ ├── quote.ts │ │ │ └── spoiler.ts │ │ └── replacement-values │ │ │ ├── file.ts │ │ │ └── poll.ts │ ├── source.ts │ ├── stats.ts │ ├── token.ts │ └── user.ts ├── fixtures │ ├── accessor.ts │ ├── client.ts │ ├── index.ts │ ├── poll.ts │ ├── post.ts │ └── user.ts ├── layouts │ ├── default.vue │ ├── error.vue │ ├── loading.vue │ └── no-sidebar.vue ├── pages │ ├── about │ │ ├── bookmarklet.vue │ │ ├── index.vue │ │ └── stats.vue │ ├── at_name.vue │ ├── at_name │ │ ├── followers.vue │ │ ├── follows.vue │ │ ├── index.vue │ │ ├── posts │ │ │ └── _id │ │ │ │ ├── index.vue │ │ │ │ └── revisions.vue │ │ └── starred.vue │ ├── callback.vue │ ├── channels │ │ ├── _channelId.vue │ │ └── index.vue │ ├── conversations.vue │ ├── files │ │ ├── _id.vue │ │ └── index.vue │ ├── global.vue │ ├── index.vue │ ├── intent │ │ └── post.vue │ ├── interactions.vue │ ├── logout.vue │ ├── mentions.vue │ ├── missed-conversations.vue │ ├── newcomers.vue │ ├── photos.vue │ ├── polls │ │ ├── _id.vue │ │ └── index.vue │ ├── posts │ │ └── _id │ │ │ ├── index.vue │ │ │ └── revisions.vue │ ├── search │ │ ├── posts.vue │ │ └── users.vue │ ├── settings.vue │ ├── settings │ │ ├── blocked-accounts.vue │ │ ├── display.vue │ │ ├── index.vue │ │ ├── muted-accounts.vue │ │ ├── notifications.vue │ │ └── stream.vue │ ├── stars.vue │ ├── tags │ │ └── _name.vue │ └── trending.vue ├── plugins │ ├── axios │ │ └── index.ts │ ├── composition-api.ts │ ├── created.ts │ ├── dayjs.ts │ ├── di │ │ ├── bind.ts │ │ ├── index.ts │ │ └── interactors.ts │ ├── domain │ │ ├── dto │ │ │ ├── channel.ts │ │ │ ├── common.ts │ │ │ ├── file.ts │ │ │ ├── marker.ts │ │ │ ├── message.ts │ │ │ ├── poll.ts │ │ │ ├── post.ts │ │ │ ├── streamType.ts │ │ │ └── user.ts │ │ ├── entity │ │ │ └── ModifiedFile.ts │ │ ├── repository │ │ │ ├── configStorage.ts │ │ │ └── pnutRepository.ts │ │ ├── usecases │ │ │ ├── abstractCreatePost.ts │ │ │ ├── createChannel.ts │ │ │ ├── createConnection.ts │ │ │ ├── createFile.ts │ │ │ ├── createMessage.ts │ │ │ ├── createPoll.ts │ │ │ ├── createPost.ts │ │ │ ├── createPrivateChannel.ts │ │ │ ├── existingPm.ts │ │ │ ├── getChannels.ts │ │ │ ├── getFile.ts │ │ │ ├── getFiles.ts │ │ │ ├── getInteractions.ts │ │ │ ├── getMessages.ts │ │ │ ├── getPoll.ts │ │ │ ├── getPolls.ts │ │ │ ├── getPosts.ts │ │ │ ├── getProfile.ts │ │ │ ├── getRevision.ts │ │ │ ├── getStats.ts │ │ │ ├── getThread.ts │ │ │ ├── getUnreadCount.ts │ │ │ ├── getUsers.ts │ │ │ ├── markAsRead.ts │ │ │ ├── search.ts │ │ │ ├── suggestUsers.ts │ │ │ ├── updateAvatar.ts │ │ │ ├── updateCover.ts │ │ │ ├── updatePost.ts │ │ │ ├── updateProfile.ts │ │ │ ├── updateRelation.ts │ │ │ └── usecase.ts │ │ └── util │ │ │ ├── postUtil.ts │ │ │ └── util.ts │ ├── emoji.ts │ ├── emojify │ │ ├── Emojify.vue │ │ └── index.ts │ ├── font-awesome.ts │ ├── infrastructure │ │ └── repository │ │ │ ├── configStorageImpl.ts │ │ │ └── pnutRepositoryImpl.ts │ ├── intersection-observer.client.ts │ ├── modal │ │ ├── PromiseModal.vue │ │ ├── index.ts │ │ ├── mediator.ts │ │ └── modal.ts │ ├── mousetrap.ts │ ├── vue-outside.ts │ └── vue-scrollto.ts ├── static │ ├── favicon.ico │ └── img │ │ └── beta.png ├── store │ └── index.ts └── util │ ├── channel.ts │ ├── createRawList.ts │ ├── minimum-entities.ts │ └── vue.ts ├── tsconfig.json ├── types ├── emoji-mart-vue-fast.d.ts ├── index.d.ts ├── mousetrap.d.ts ├── svg.d.ts ├── unicode-substring.d.ts ├── vue-on-click-outside.d.ts ├── vue-shim.d.ts └── vuedraggable.d.ts └── yarn.lock /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | eslint: 2 | enabled: true 3 | config: 4 | extensions: 5 | - .js 6 | - .ts 7 | - .vue 8 | ratings: 9 | paths: 10 | - '**.js' 11 | - '**.ts' 12 | - '**.vue' 13 | 14 | exclude_patterns: 15 | - '__tests__/' 16 | - '__mocks__/' 17 | - '**/*.stories.ts' 18 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = 2 7 | indent_size = 2 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | --- 5 | 6 | **Describe the bug** 7 | A clear and concise description of what the bug is. 8 | 9 | **To Reproduce** 10 | Steps to reproduce the behavior: 11 | 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Desktop (please complete the following information):** 24 | 25 | - OS: [e.g. iOS] 26 | - Browser [e.g. chrome, safari] 27 | - Version [e.g. 22] 28 | 29 | **Smartphone (please complete the following information):** 30 | 31 | - Device: [e.g. iPhone6] 32 | - OS: [e.g. iOS8.1] 33 | - Browser [e.g. stock browser, safari] 34 | - Version [e.g. 22] 35 | 36 | **Additional context** 37 | Add any other context about the problem here. 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | --- 5 | 6 | **Is your feature request related to a problem? Please describe.** 7 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 8 | 9 | **Describe the solution you'd like** 10 | A clear and concise description of what you want to happen. 11 | 12 | **Describe alternatives you've considered** 13 | A clear and concise description of any alternative solutions or features you've considered. 14 | 15 | **Additional context** 16 | Add any other context or screenshots about the feature request here. 17 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [14.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v2 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | - name: Get yarn cache directory path 24 | id: yarn-cache-dir-path 25 | run: echo "::set-output name=dir::$(yarn cache dir)" 26 | - uses: actions/cache@v2 27 | id: yarn-cache 28 | with: 29 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 30 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 31 | restore-keys: | 32 | ${{ runner.os }}-yarn- 33 | - name: Install modules 34 | run: yarn 35 | - run: yarn ci 36 | env: 37 | COVERAGE_FILE: coverage/lcov.info 38 | - run: yarn build 39 | - name: Send coverage report 40 | uses: paambaati/codeclimate-action@v2.7.5 41 | env: 42 | CC_TEST_REPORTER_ID: 06f5b199c1d9bb6f814adf03807ad1df9b6184d4a268daba4672f4f95418ac7e 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/node 3 | 4 | ### Node ### 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | 24 | # nyc test coverage 25 | .nyc_output 26 | 27 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 28 | .grunt 29 | 30 | # Bower dependency directory (https://bower.io/) 31 | bower_components 32 | 33 | # node-waf configuration 34 | .lock-wscript 35 | 36 | # Compiled binary addons (http://nodejs.org/api/addons.html) 37 | build/Release 38 | 39 | # Dependency directories 40 | node_modules/ 41 | jspm_packages/ 42 | 43 | # Typescript v1 declaration files 44 | typings/ 45 | 46 | # Optional npm cache directory 47 | .npm 48 | 49 | # Optional eslint cache 50 | .eslintcache 51 | 52 | # Optional REPL history 53 | .node_repl_history 54 | 55 | # Output of 'npm pack' 56 | *.tgz 57 | 58 | # Yarn Integrity file 59 | .yarn-integrity 60 | 61 | # dotenv environment variables file 62 | .env 63 | 64 | 65 | 66 | # End of https://www.gitignore.io/api/node 67 | 68 | # Nuxt build 69 | .nuxt 70 | 71 | # Nuxt generate 72 | dist 73 | 74 | sw.* 75 | 76 | .vscode 77 | __screenshots__ 78 | .reg 79 | .nuxt-storybook 80 | storybook-static 81 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "htmlWhitespaceSensitivity": "ignore" 5 | } 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | 1. Fork it! 4 | 2. Create a branch (git checkout -b new-feature) 5 | 3. Commit your changes (git commit -am "Add a feature") 6 | 4. Push to the branch (git push origin new-feature) 7 | 5. Create a new Pull Request 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:latest 2 | 3 | # Create app directory 4 | RUN mkdir -p /usr/src/app 5 | WORKDIR /usr/src/app 6 | 7 | COPY package.json /usr/src/app/ 8 | 9 | RUN yarn 10 | 11 | COPY . /usr/src/app 12 | 13 | ENV CLIENT_ID pJ2VRJzLBwBitL6ZJoiXOLeamCxRs8Bw 14 | RUN yarn run build 15 | EXPOSE 3000 16 | 17 | CMD [ "yarn", "start" ] 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 sunya 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Beta 2 | 3 | ![Node.js CI](https://github.com/sunya9/beta/workflows/Node.js%20CI/badge.svg) 4 | [![dependencies Status](https://david-dm.org/sunya9/beta/status.svg)](https://david-dm.org/sunya9/beta) 5 | [![devDependencies Status](https://david-dm.org/sunya9/beta/dev-status.svg)](https://david-dm.org/sunya9/beta?type=dev) 6 | [![Maintainability](https://api.codeclimate.com/v1/badges/fdb75749d11567b69c97/maintainability)](https://codeclimate.com/github/sunya9/beta/maintainability) 7 | [![Test Coverage](https://api.codeclimate.com/v1/badges/fdb75749d11567b69c97/test_coverage)](https://codeclimate.com/github/sunya9/beta/test_coverage) 8 | 9 | pnut.io client. 10 | 11 | ## Build Setup 12 | 13 | ```bash 14 | # install dependencies 15 | $ npm install # Or yarn install*[see note below] 16 | 17 | # serve with hot reload at localhost:3000 18 | $ npm run dev 19 | 20 | # build for production and launch server 21 | $ npm run build 22 | $ npm start 23 | 24 | # generate static project 25 | $ npm run generate 26 | ``` 27 | 28 | \*Note: Due to a bug in yarn's engine version detection code if you are 29 | using a prerelease version of Node (i.e. v7.6.0-rc.1) you will need to either: 30 | 31 | 1. Use `npm install` 32 | 2. Run `yarn` with a standard release of Node and then switch back 33 | 34 | For detailed explanation on how things work, checkout the [Nuxt.js docs](https://github.com/nuxt/nuxt.js). 35 | 36 | ## Client Setup 37 | 38 | Set up a client in the pnut.io developer area. 39 | 40 | Set environment variables referenced in [example.env](example.env). 41 | -------------------------------------------------------------------------------- /__mocks__/bootstrap.native.ts: -------------------------------------------------------------------------------- 1 | function noop() {} 2 | const Collapse = noop 3 | const Popover = noop 4 | 5 | const toggleBoolStr = (b: string | null) => !(b === 'true') 6 | 7 | class Dropdown { 8 | el: HTMLElement 9 | btn: HTMLElement | null = null 10 | constructor(dropdownEl: HTMLElement) { 11 | this.el = dropdownEl 12 | if (!this.el) return 13 | this.btn = this.el.querySelector('[data-toggle="dropdown"]') 14 | if (!this.btn) return 15 | this.btn.addEventListener('click', this.toggle.bind(this)) 16 | } 17 | 18 | toggle() { 19 | const b = this.el.getAttribute('aria-expanded') 20 | this.el.setAttribute('aria-expanded', `${toggleBoolStr(b)}`) 21 | } 22 | 23 | show() {} 24 | } 25 | 26 | class Modal { 27 | el: HTMLElement 28 | constructor(el: HTMLElement) { 29 | this.el = el 30 | } 31 | 32 | show() {} 33 | hide() {} 34 | } 35 | const Tab = noop 36 | 37 | export default { 38 | Collapse, 39 | Popover, 40 | Dropdown, 41 | Modal, 42 | Tab, 43 | } 44 | -------------------------------------------------------------------------------- /__mocks__/styleMock.ts: -------------------------------------------------------------------------------- 1 | export default {} 2 | -------------------------------------------------------------------------------- /__tests__/assets/ts/oembed.spec.ts: -------------------------------------------------------------------------------- 1 | import { createVideoEmbedRaw } from '~/assets/ts/oembed' 2 | import { OEmbed } from '~/entity/raw/raw/oembed' 3 | 4 | describe('oembed', () => { 5 | test('createVideoEmbedRaw', () => { 6 | const res = createVideoEmbedRaw( 7 | 'lorem https://www.youtube.com/watch?v=exam-ple_ http://youtu.be/123exXmp-le_ ipsum' 8 | ) 9 | const el0: OEmbed.Video = { 10 | type: 'io.pnut.core.oembed', 11 | value: { 12 | version: '1.0', 13 | type: 'video', 14 | width: 480, 15 | height: 270, 16 | html: '', 17 | embeddable_url: 'https://www.youtube.com/watch?v=exam-ple_', 18 | }, 19 | } 20 | expect(res[0]).toStrictEqual(el0) 21 | 22 | const el1: OEmbed.Video = { 23 | type: 'io.pnut.core.oembed', 24 | value: { 25 | version: '1.0', 26 | type: 'video', 27 | width: 480, 28 | height: 270, 29 | html: '', 30 | embeddable_url: 'http://youtu.be/123exXmp-le_', 31 | }, 32 | } 33 | expect(res[1]).toStrictEqual(el1) 34 | 35 | expect(createVideoEmbedRaw('embeddable contents not found')).toStrictEqual( 36 | [] 37 | ) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /__tests__/components/ActionButton.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import ActionButton from '~/components/atoms/ActionButton.vue' 3 | 4 | describe('ActionButton', () => { 5 | describe('icon props', () => { 6 | test('pass string', () => { 7 | const wrapper = mount(ActionButton, { 8 | propsData: { 9 | icon: 'user', 10 | checked: false, 11 | }, 12 | }) 13 | expect(wrapper.find('.fa-user').exists()).toBe(true) 14 | }) 15 | test('pass array', () => { 16 | const wrapper = mount(ActionButton, { 17 | propsData: { 18 | icon: ['user', 'users'], 19 | checked: false, 20 | }, 21 | }) 22 | expect(wrapper.find('.fa-user').exists()).toBe(true) 23 | expect(wrapper.find('.fa-users').exists()).toBe(false) 24 | const wrapper2 = mount(ActionButton, { 25 | propsData: { 26 | icon: ['user', 'users'], 27 | checked: true, 28 | }, 29 | }) 30 | expect(wrapper2.find('.fa-user').exists()).toBe(false) 31 | expect(wrapper2.find('.fa-users').exists()).toBe(true) 32 | }) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /__tests__/components/AppDropdown.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import AppDropdown from '~/components/molecules/AppDropdown.vue' 3 | 4 | describe('AppDropdown', () => { 5 | test('render', () => { 6 | const wrapper = mount(AppDropdown, { 7 | propsData: { 8 | value: false, 9 | }, 10 | scopedSlots: { 11 | button: '', 12 | default: '
content
', 13 | }, 14 | }) 15 | expect(wrapper.vm).toBeTruthy() 16 | }) 17 | test('receive input event when clicked', async () => { 18 | const wrapper = mount(AppDropdown, { 19 | propsData: { 20 | value: false, 21 | }, 22 | scopedSlots: { 23 | button: '', 24 | default: '
content
', 25 | }, 26 | attachTo: document.body, 27 | }) 28 | const button = wrapper.find('button') 29 | 30 | expect(wrapper.find('#content').element).toBeFalsy() 31 | await button.trigger('click') 32 | expect(wrapper.emitted('input')?.[0][0]).toBe(true) 33 | 34 | await wrapper.setProps({ 35 | value: true, 36 | }) 37 | 38 | expect(wrapper.find('#content').element).toBeVisible() 39 | await button.trigger('click') 40 | expect(wrapper.emitted('input')?.[1][0]).toBe(false) 41 | 42 | document.dispatchEvent(new MouseEvent('click')) 43 | expect(wrapper.emitted('input')?.[2][0]).toBe(false) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /__tests__/components/ChatPanel.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount, fixtures, createStore } from '../helper' 2 | import ChatPanel from '~/components/organisms/ChatPanel.vue' 3 | import { Channel } from '~/entity/channel' 4 | import { ChatRoomSettings } from '~/entity/raw/raw/chat-room-settings' 5 | 6 | describe('ChatPanel component', () => { 7 | test('Show RSS Link when publicly channel', () => { 8 | const chat = fixtures('channel', 'chat')! 9 | .raw![0] as ChatRoomSettings 10 | const wrapper = shallowMount(ChatPanel, { 11 | propsData: { 12 | initialChannel: fixtures('channel', 'publicly', 'chat'), 13 | chat, 14 | }, 15 | mocks: { 16 | $metaInfo: {}, 17 | $store: createStore(), 18 | }, 19 | }) 20 | expect( 21 | wrapper 22 | .find('a[href^="https://api.pnut.io/v0/feed/rss/channels/"]') 23 | .exists() 24 | ).toBe(true) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /__tests__/components/CustomCheckbox.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '../helper' 2 | import CustomCheckbox from '~/components/atoms/CustomCheckbox.vue' 3 | 4 | describe('CustomCheckbox component', () => { 5 | describe('props', () => { 6 | test('Disabled checkbox when pass disabled', () => { 7 | const wrapper = mount(CustomCheckbox, { 8 | propsData: { 9 | disabled: true, 10 | checked: false, 11 | }, 12 | }) 13 | expect((wrapper.find('input').element as HTMLInputElement).disabled).toBe( 14 | true 15 | ) 16 | }) 17 | test('checked when pass checked = `true`', () => { 18 | const wrapper = mount(CustomCheckbox, { 19 | propsData: { 20 | checked: true, 21 | }, 22 | }) 23 | expect((wrapper.find('input').element as HTMLInputElement).checked).toBe( 24 | true 25 | ) 26 | }) 27 | }) 28 | test('toggle checkbox when clicked', async () => { 29 | const wrapper = mount(CustomCheckbox, { 30 | propsData: { 31 | checked: false, 32 | }, 33 | }) 34 | const $input = wrapper.find('input') 35 | expect(($input.element as HTMLInputElement).checked).toBe(false) 36 | $input.trigger('click') 37 | await wrapper.vm.$nextTick() 38 | expect(($input.element as HTMLInputElement).checked).toBe(true) 39 | $input.trigger('click') 40 | await wrapper.vm.$nextTick() 41 | expect(($input.element as HTMLInputElement).checked).toBe(false) 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /__tests__/components/EntityText.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount, fixtures } from '../helper' 2 | import EntityText from '~/components/atoms/EntityText.vue' 3 | import { Post } from '~/entity/post' 4 | 5 | // TODO: write tests 6 | describe('EntityText component', () => { 7 | it('Replace patter links with own domain', () => { 8 | const content = fixtures('post', 'hasPatterLink').content 9 | const wrapper = mount(EntityText, { 10 | propsData: { 11 | content, 12 | }, 13 | }) 14 | expect(wrapper.text().includes('https://beta.pnut.io/channels/0')).toBe( 15 | true 16 | ) 17 | }) 18 | 19 | // https://github.com/sunya9/beta/issues/221 20 | test('Markdown links are displayed correctly', () => { 21 | const wrapper = mount(EntityText, { 22 | propsData: { 23 | content: fixtures('post', 'hasMarkdownLink').content, 24 | }, 25 | }) 26 | // hacky: @vue/test-utils does not remove spaces between tags 27 | // (vue renderer remove all whitespaces between tags default) 28 | expect(wrapper.html()).toContain( 29 | 'Beta [beta.pnut.io]' 30 | ) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /__tests__/components/InputLongost.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount } from '../helper' 2 | 3 | import InputLongpost from '~/components/organisms/InputLongpost.vue' 4 | 5 | describe('InputLongpost component', () => { 6 | test('initial data is empty', () => { 7 | const wrapper = shallowMount(InputLongpost) 8 | expect((wrapper.find('input').element as HTMLInputElement).value).toBe('') 9 | expect( 10 | (wrapper.find('textarea').element as HTMLTextAreaElement).value 11 | ).toBe('') 12 | }) 13 | test('emitted update:longpost event when longpost is changed', async () => { 14 | const wrapper = shallowMount(InputLongpost) 15 | wrapper.find('input').setValue('foo') 16 | await wrapper.vm.$nextTick() 17 | expect(wrapper.emitted()['update:longpost']?.[0][0]).toEqual({ 18 | title: 'foo', 19 | body: '', 20 | }) 21 | wrapper.find('textarea').setValue('bar') 22 | expect(wrapper.emitted()['update:longpost']?.[1][0]).toEqual({ 23 | title: 'foo', 24 | body: 'bar', 25 | }) 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /__tests__/components/InputLongpost.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount } from '../helper' 2 | 3 | import InputLongpost from '~/components/organisms/InputLongpost.vue' 4 | 5 | describe('InputLongpost component', () => { 6 | test('initial data is empty', () => { 7 | const wrapper = shallowMount(InputLongpost) 8 | expect((wrapper.find('input').element as HTMLInputElement).value).toBe('') 9 | expect( 10 | (wrapper.find('textarea').element as HTMLTextAreaElement).value 11 | ).toBe('') 12 | }) 13 | test('emitted update:longpost event when longpost is changed', async () => { 14 | const wrapper = shallowMount(InputLongpost) 15 | wrapper.find('input').setValue('foo') 16 | await wrapper.vm.$nextTick() 17 | expect(wrapper.emitted()['update:longpost']?.[0][0]).toEqual({ 18 | title: 'foo', 19 | body: '', 20 | }) 21 | wrapper.find('textarea').setValue('bar') 22 | expect(wrapper.emitted()['update:longpost']?.[1][0]).toEqual({ 23 | title: 'foo', 24 | body: 'bar', 25 | }) 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /__tests__/components/Nsfw.spec.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { Wrapper } from '@vue/test-utils' 3 | import { shallowMount } from '../helper' 4 | import Nsfw from '~/components/organisms/Nsfw.vue' 5 | 6 | describe('NSFW component', () => { 7 | describe('When post does not include nsfw', () => { 8 | test('Show contents as it is', () => { 9 | const wrapper = shallowMount(Nsfw, { 10 | propsData: { 11 | includeNsfw: false, 12 | }, 13 | slots: { 14 | default: 'Not nsfw', 15 | }, 16 | }) 17 | expect(wrapper.text()).toContain('Not nsfw') 18 | expect(wrapper.text()).not.toContain('This post includes NSFW') 19 | }) 20 | }) 21 | describe('When post includes nsfw', () => { 22 | let wrapper: Wrapper 23 | beforeEach(() => { 24 | wrapper = shallowMount(Nsfw, { 25 | propsData: { 26 | includeNsfw: true, 27 | }, 28 | slots: { 29 | default: 'nsfw contents', 30 | }, 31 | }) 32 | }) 33 | test('Hide contents', () => { 34 | expect(wrapper.text()).not.toContain('nsfw contents') 35 | expect(wrapper.text()).toContain('This post includes NSFW') 36 | }) 37 | test('Show contents when clicked button', async () => { 38 | wrapper.find('button').trigger('click') 39 | await wrapper.vm.$nextTick() 40 | expect(wrapper.text()).toContain('nsfw contents') 41 | expect(wrapper.text()).not.toContain('This post includes NSFW') 42 | }) 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /__tests__/components/NuxtLinkMod.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount, RouterLinkStub as NuxtLink } from '../helper' 2 | import NuxtLinkMod from '~/components/atoms/NuxtLinkMod.vue' 3 | 4 | describe('NuxtLinkMod component', () => { 5 | test('If include own domain, use ', () => { 6 | const wrapper = mount(NuxtLinkMod, { 7 | propsData: { 8 | to: 'https://beta.pnut.io/mentions', 9 | }, 10 | stubs: { 11 | NuxtLink, 12 | }, 13 | }) 14 | const nuxtLinkWrapper = wrapper.findComponent(NuxtLink) 15 | expect(nuxtLinkWrapper.exists()).toBe(true) 16 | expect(nuxtLinkWrapper.props().to).toBe('/mentions') 17 | }) 18 | test('If not include own domain, use ', () => { 19 | const wrapper = mount(NuxtLinkMod, { 20 | propsData: { 21 | to: 'https://example.com/foo', 22 | }, 23 | stubs: { 24 | NuxtLink, 25 | }, 26 | }) 27 | const aWrapper = wrapper.find('a') 28 | expect(aWrapper.exists()).toBe(true) 29 | expect(aWrapper.attributes().href).toBe('https://example.com/foo') 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /__tests__/components/PostList.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | mount, 3 | fixtures, 4 | baseMountOpts, 5 | authedUserCreateStore, 6 | authedAccessor, 7 | } from '../helper' 8 | import PostList from '~/components/organisms/PostList.vue' 9 | import { createListInfo } from '~/plugins/domain/util/util' 10 | 11 | const posts = [fixtures('post'), fixtures('post', 'main', 'replyTo')] 12 | 13 | describe('PostList component', () => { 14 | describe('detail', () => { 15 | let wrapper: ReturnType 16 | beforeEach(async () => { 17 | wrapper = mount( 18 | PostList, 19 | baseMountOpts({ 20 | propsData: { 21 | listInfo: await createListInfo(() => 22 | Promise.resolve({ meta: { code: 200 }, data: posts }) 23 | ), 24 | main: '2', 25 | }, 26 | mocks: { 27 | $store: authedUserCreateStore(), 28 | $accessor: authedAccessor(), 29 | }, 30 | }) 31 | ) 32 | }) 33 | test('highlight the target post when the main post has reply_to', () => { 34 | expect(wrapper.find('.list-group-item-warning').exists()).toBe(true) 35 | }) 36 | test('main post has vertical margin', () => { 37 | expect(wrapper.find('.list-group-item.mb-4').exists()).toBe(true) 38 | }) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /__tests__/components/PostModal.spec.ts: -------------------------------------------------------------------------------- 1 | import { Wrapper } from '@vue/test-utils' 2 | import { 3 | mount, 4 | authedUserCreateStore, 5 | RouterLinkStub as NuxtLink, 6 | fixtures, 7 | authedAccessor, 8 | } from '../helper' 9 | import PostModal from '~/components/organisms/PostModal.vue' 10 | import { Post } from '~/entity/post' 11 | 12 | type PostModalType = InstanceType & { 13 | show: (post: Post) => void 14 | } 15 | 16 | describe('PostModal component', () => { 17 | let wrapper: Wrapper 18 | beforeEach(() => { 19 | wrapper = mount(PostModal, { 20 | mocks: { 21 | $store: authedUserCreateStore(), 22 | $accessor: authedAccessor(), 23 | }, 24 | stubs: { 25 | Compose: true, 26 | NuxtLink, 27 | post: true, 28 | }, 29 | }) as Wrapper 30 | }) 31 | test('show post when receive post argument', async () => { 32 | wrapper.vm.show(fixtures('post')) 33 | await wrapper.vm.$nextTick() 34 | expect(wrapper.find('post-stub').exists()).toBe(true) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /__tests__/components/Splash.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount } from '../helper' 2 | import Splash from '~/components/molecules/Splash.vue' 3 | 4 | describe('Splash component', () => { 5 | test('Called $auth.loginWith when login button is clicked', () => { 6 | const wrapper = shallowMount(Splash) 7 | wrapper.find('button').trigger('click') 8 | expect(wrapper.vm.$auth.loginWith).toHaveBeenCalled() 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /__tests__/components/ToggleLongpost.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import ToggleLongpost from '~/components/atoms/ToggleLongpost.vue' 3 | 4 | describe('ToggleLongpost', () => { 5 | test('render without crash', () => { 6 | const wrapper = mount(ToggleLongpost, { 7 | propsData: { 8 | value: null, 9 | }, 10 | }) 11 | expect(wrapper.text()).toContain('Long') 12 | expect(wrapper.find('svg').attributes('data-icon')).toBe('plus') 13 | }) 14 | test('toggle value', async () => { 15 | const input = jest.fn() 16 | const wrapper = mount(ToggleLongpost, { 17 | propsData: { 18 | value: null, 19 | }, 20 | listeners: { 21 | input, 22 | }, 23 | }) 24 | await wrapper.trigger('click') 25 | expect(wrapper.emitted('input')?.[0][0].body).toBe('') 26 | expect(wrapper.emitted('input')?.[0][0].tstamp).toEqual( 27 | expect.stringMatching(/\d+/) 28 | ) 29 | expect(input).toBeCalledTimes(1) 30 | await wrapper.setProps({ 31 | value: { 32 | body: '', 33 | tstamp: Date.now().toString(), 34 | }, 35 | }) 36 | await wrapper.trigger('click') 37 | expect(wrapper.emitted('input')?.[1][0]).toBe(null) 38 | expect(input).toBeCalledTimes(2) 39 | await wrapper.setProps({ 40 | value: null, 41 | }) 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /__tests__/components/ToggleNsfw.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import ToggleNsfw from '~/components/atoms/ToggleNsfw.vue' 3 | import ToggleButton from '~/components/atoms/ToggleButton.vue' 4 | 5 | describe('ToggleNSFW', () => { 6 | test('render without crash', () => { 7 | const wrapper = mount(ToggleNsfw, { 8 | propsData: { 9 | value: false, 10 | }, 11 | }) 12 | expect(wrapper.text()).toContain('NSFW') 13 | }) 14 | test('toggle value', async () => { 15 | const input = jest.fn() 16 | const wrapper = mount(ToggleNsfw, { 17 | propsData: { 18 | value: false, 19 | }, 20 | listeners: { 21 | input, 22 | }, 23 | }) 24 | const toggleButtonWrapper = wrapper.findComponent(ToggleButton) 25 | expect(wrapper.classes()).toContain('text-dark') 26 | expect(wrapper.classes()).not.toContain('btn-primary') 27 | await wrapper.trigger('click') 28 | expect(toggleButtonWrapper.emitted('input')?.[0][0]).toBe(true) 29 | expect(input).toBeCalledTimes(1) 30 | await wrapper.setProps({ 31 | value: true, 32 | }) 33 | expect(wrapper.classes()).not.toContain('text-dark') 34 | expect(wrapper.classes()).toContain('btn-primary') 35 | await wrapper.trigger('click') 36 | expect(toggleButtonWrapper.emitted('input')?.[1][0]).toBe(false) 37 | expect(input).toBeCalledTimes(2) 38 | await wrapper.setProps({ 39 | value: false, 40 | }) 41 | expect(wrapper.classes()).not.toContain('btn-primary') 42 | expect(wrapper.classes()).toContain('text-dark') 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /__tests__/components/ToggleSpoiler.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import ToggleSpoiler from '~/components/atoms/ToggleSpoiler.vue' 3 | 4 | describe('ToggleSpoiler', () => { 5 | test('render without crash', () => { 6 | const wrapper = mount(ToggleSpoiler, { 7 | propsData: { 8 | value: null, 9 | }, 10 | }) 11 | expect(wrapper.text()).toContain('Spoiler') 12 | expect(wrapper.find('svg').attributes('data-icon')).toBe('bell') 13 | }) 14 | test('toggle value', async () => { 15 | const input = jest.fn() 16 | const wrapper = mount(ToggleSpoiler, { 17 | propsData: { 18 | value: null, 19 | }, 20 | listeners: { 21 | input, 22 | }, 23 | }) 24 | await wrapper.trigger('click') 25 | expect(wrapper.emitted('input')?.[0][0]).toStrictEqual({ 26 | topic: '', 27 | }) 28 | expect(input).toBeCalledTimes(1) 29 | await wrapper.setProps({ 30 | value: { 31 | topic: '', 32 | }, 33 | }) 34 | await wrapper.trigger('click') 35 | expect(wrapper.emitted('input')?.[1][0]).toBe(null) 36 | expect(input).toBeCalledTimes(2) 37 | await wrapper.setProps({ 38 | value: null, 39 | }) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /__tests__/components/atoms/RelationBadge.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import { authedAccessor } from '~/../__tests__/helper' 3 | import RelationBadge from '~/components/atoms/RelationBadge.vue' 4 | import * as userFixtures from '~/fixtures/user' 5 | 6 | describe('RelationBadge', () => { 7 | test('render badge', () => { 8 | const user = userFixtures.followerUser 9 | const wrapper = mount(RelationBadge, { 10 | propsData: { 11 | user, 12 | }, 13 | mocks: { 14 | $accessor: authedAccessor, 15 | }, 16 | }) 17 | expect(wrapper.vm).toBeTruthy() 18 | expect(wrapper.element).toBeVisible() 19 | expect(wrapper.text()).toContain('Follows you') 20 | }) 21 | test('hide when not followed', () => { 22 | const wrapper = mount(RelationBadge, { 23 | propsData: { 24 | user: userFixtures.anotherUser, 25 | }, 26 | mocks: { 27 | $accessor: authedAccessor, 28 | }, 29 | }) 30 | expect(wrapper.element.nodeType).toBe(Node.COMMENT_NODE) 31 | expect(wrapper.text()).not.toContain('Follows you') 32 | }) 33 | 34 | test("hide when it's me", () => { 35 | const wrapper = mount(RelationBadge, { 36 | propsData: { 37 | user: userFixtures.myselfEntity, 38 | }, 39 | mocks: { 40 | $accessor: authedAccessor, 41 | }, 42 | }) 43 | expect(wrapper.element.nodeType).toBe(Node.COMMENT_NODE) 44 | expect(wrapper.text()).not.toContain('Follows you') 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /__tests__/components/atoms/TogglePoll.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import TogglePoll from '~/components/atoms/TogglePoll.vue' 3 | 4 | describe('TogglePoll', () => { 5 | test('render', () => { 6 | const wrapper = mount(TogglePoll) 7 | expect(wrapper.vm).toBeTruthy() 8 | expect(wrapper.find('svg').attributes('data-icon')).toBe('chart-bar') 9 | }) 10 | 11 | test('toggle state', async () => { 12 | const wrapper = mount(TogglePoll, { 13 | propsData: { 14 | value: null, 15 | }, 16 | }) 17 | await wrapper.trigger('click') 18 | expect(wrapper.emitted('input')?.[0][0]).toStrictEqual({ 19 | prompt: '', 20 | type: 'net.unsweets.beta', 21 | options: [], 22 | duration: 1440, 23 | }) 24 | 25 | await wrapper.setProps({ 26 | value: { 27 | prompt: '', 28 | type: 'net.unsweets.beta', 29 | options: [], 30 | duration: 1440, 31 | }, 32 | }) 33 | await wrapper.trigger('click') 34 | expect(wrapper.emitted('input')?.[1][0]).toBeNull() 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /__tests__/components/molecules/EmojiPicker.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import { Picker } from 'emoji-mart-vue-fast' 3 | import EmojiPicker from '~/components/molecules/EmojiPicker.vue' 4 | import EmojiButton from '~/components/atoms/EmojiButton.vue' 5 | 6 | describe('EmojiPicker', () => { 7 | test('render', () => { 8 | const wrapper = mount(EmojiPicker) 9 | expect(wrapper.vm).toBeTruthy() 10 | }) 11 | test('show palette when click', async () => { 12 | const wrapper = mount(EmojiPicker) 13 | const pickerWrapper = wrapper.findComponent(Picker) 14 | expect(pickerWrapper.element).not.toBeVisible() 15 | await wrapper.findComponent(EmojiButton).trigger('click') 16 | expect(pickerWrapper.element).toBeVisible() 17 | await wrapper.findComponent(EmojiButton).trigger('click') 18 | expect(pickerWrapper.element).not.toBeVisible() 19 | // TODO: click out of side test 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /__tests__/components/molecules/Sound.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import Sound from '~/components/molecules/Sound.vue' 3 | describe('Sound component', () => { 4 | test('render correctly', () => { 5 | const wrapper = mount(Sound, { 6 | propsData: { 7 | title: 'title', 8 | removable: true, 9 | }, 10 | }) 11 | expect(wrapper.vm).toBeTruthy() 12 | expect(wrapper.text()).toContain('title') 13 | expect(wrapper.find('svg').attributes('data-icon')).toBe('times') 14 | }) 15 | 16 | test('receive remove event when click button', async () => { 17 | const wrapper = mount(Sound, { 18 | propsData: { 19 | removable: true, 20 | }, 21 | }) 22 | await wrapper.find('a').trigger('click') 23 | expect(wrapper.emitted('remove')?.[0]).toStrictEqual([]) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /__tests__/components/organisms/LongPost.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import LongPost from '~/components/organisms/LongPost.vue' 3 | 4 | const longPost = { 5 | body: 'body', 6 | title: 'title', 7 | } as const 8 | 9 | describe('LongPost', () => { 10 | test('render', () => { 11 | const wrapper = mount(LongPost, { 12 | propsData: { 13 | longPost, 14 | }, 15 | }) 16 | expect(wrapper.html()).toBeTruthy() 17 | }) 18 | test('toggle visibility', async () => { 19 | const wrapper = mount(LongPost, { 20 | propsData: { 21 | longPost, 22 | }, 23 | }) 24 | expect(wrapper.text()).not.toContain('body') 25 | expect(wrapper.text()).not.toContain('title') 26 | expect(wrapper.text()).toContain('Expand Post') 27 | 28 | await wrapper.find('button').trigger('click') 29 | 30 | expect(wrapper.text()).toContain('body') 31 | expect(wrapper.text()).toContain('title') 32 | expect(wrapper.text()).toContain('Collapse Post') 33 | 34 | await wrapper.find('button').trigger('click') 35 | 36 | expect(wrapper.text()).not.toContain('body') 37 | expect(wrapper.text()).not.toContain('title') 38 | expect(wrapper.text()).toContain('Expand Post') 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /__tests__/components/settings/Account/index.spec.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { Wrapper } from '@vue/test-utils' 3 | import { shallowMount, stub } from '../../../helper' 4 | import Index from '~/components/molecules/settings/Account/index.vue' 5 | 6 | describe('settings/Account/index component', () => { 7 | let wrapper: Wrapper 8 | beforeEach(() => { 9 | wrapper = shallowMount(Index, { 10 | propsData: { 11 | account: { 12 | name: 'foo', 13 | content: { 14 | text: 'bar', 15 | }, 16 | timezone: '', 17 | locale: '', 18 | }, 19 | }, 20 | stubs: { 21 | Avatar: stub, 22 | Cover: stub, 23 | }, 24 | }) 25 | wrapper.vm.$toast.error = jest.fn() 26 | wrapper.vm.$toast.success = jest.fn() 27 | }) 28 | test('show success toast', async () => { 29 | wrapper.vm.$axios.$patch = jest.fn() 30 | await wrapper.find('form').trigger('submit') 31 | expect(wrapper.vm.$axios.$patch).toBeCalled() 32 | expect(wrapper.vm.$toast.success).toHaveBeenCalled() 33 | expect(wrapper.vm.$toast.error).not.toHaveBeenCalled() 34 | }) 35 | test('show error toast', async () => { 36 | wrapper.vm.$axios.$patch = jest.fn(() => { 37 | throw new Error('error') 38 | }) 39 | await wrapper.find('form').trigger('submit') 40 | expect(wrapper.vm.$axios.$patch).toThrow() 41 | expect(wrapper.vm.$toast.success).not.toHaveBeenCalled() 42 | expect(wrapper.vm.$toast.error).toHaveBeenCalled() 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /__tests__/fixtures/channel.ts: -------------------------------------------------------------------------------- 1 | import fixtures from '.' 2 | import { Channel } from '~/entity/channel' 3 | 4 | const baseChannel: Channel = { 5 | id: '1', 6 | raw: [], 7 | owner: fixtures('user'), 8 | acl: { 9 | full: { 10 | user_ids: [], 11 | immutable: true, 12 | you: false, 13 | }, 14 | write: { 15 | user_ids: [], 16 | any_user: true, 17 | immutable: true, 18 | you: true, 19 | }, 20 | read: { 21 | user_ids: [], 22 | any_user: true, 23 | immutable: true, 24 | public: true, 25 | you: true, 26 | }, 27 | }, 28 | counts: { 29 | messages: 1, 30 | subscribers: 1, 31 | }, 32 | has_unread: false, 33 | is_active: true, 34 | type: 'net.unsweets.beta', 35 | you_muted: false, 36 | you_subscribed: true, 37 | } 38 | 39 | export const publicly = { 40 | acl: { 41 | read: { 42 | public: true, 43 | }, 44 | }, 45 | } 46 | 47 | export default baseChannel 48 | 49 | export const writable = { 50 | acl: { 51 | write: { 52 | you: true, 53 | }, 54 | }, 55 | } 56 | 57 | export const chat = { 58 | raw: [ 59 | { 60 | type: 'io.pnut.core.chat-settings', 61 | value: { 62 | name: 'name', 63 | description: 'description', 64 | categories: ['general'], 65 | }, 66 | }, 67 | ], 68 | } 69 | -------------------------------------------------------------------------------- /__tests__/fixtures/client.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '~/entity/client' 2 | 3 | export const testClient: Client.Source = { 4 | id: '1', 5 | link: 'https://client.example.com/', 6 | name: 'testClient', 7 | } 8 | -------------------------------------------------------------------------------- /__tests__/fixtures/file.ts: -------------------------------------------------------------------------------- 1 | export default 'file' 2 | -------------------------------------------------------------------------------- /__tests__/fixtures/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { merge, cloneDeep } from 'lodash' 3 | 4 | export default function fixtures( 5 | filepath: string, 6 | ...overrides: string[] 7 | ): T { 8 | const defExport: { 9 | default: T 10 | [key: string]: Partial 11 | } = require(path.resolve(__dirname, filepath)) 12 | return overrides 13 | ? overrides.reduce( 14 | (res, key) => merge<{}, T, Partial>({}, res, defExport[key]), 15 | defExport.default 16 | ) 17 | : cloneDeep(defExport.default) 18 | } 19 | -------------------------------------------------------------------------------- /__tests__/fixtures/interaction.ts: -------------------------------------------------------------------------------- 1 | import fixtures from '.' 2 | import { InteractionType } from '~/entity/interaction' 3 | import { Post } from '~/entity/post' 4 | import { User } from '~/entity/user' 5 | 6 | const post = fixtures('post') 7 | 8 | const baseInteraction: InteractionType = { 9 | event_date: new Date(), 10 | pagination_id: '1', 11 | users: [fixtures('user')], 12 | // dummy 13 | action: 'reply', 14 | objects: [post], 15 | } 16 | export default baseInteraction 17 | 18 | export const reply: Partial = { 19 | action: 'reply', 20 | objects: [post], 21 | } 22 | 23 | export const bookmark: Partial = { 24 | action: 'bookmark', 25 | objects: [post], 26 | } 27 | 28 | export const repost: Partial = { 29 | action: 'repost', 30 | objects: [post], 31 | } 32 | export const follow: Partial = { 33 | action: 'follow', 34 | objects: [fixtures('user', 'notMe')], 35 | } 36 | -------------------------------------------------------------------------------- /__tests__/fixtures/poll.ts: -------------------------------------------------------------------------------- 1 | import { fixtures } from '../helper' 2 | import { testClient } from './client' 3 | import { Poll } from '~/entity/poll' 4 | import { User } from '~/entity/user' 5 | 6 | const now = new Date() 7 | const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000) 8 | const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000) 9 | 10 | const options: Poll.PollOption[] = Array(5) 11 | .fill(undefined) 12 | .map((_, i) => ({ 13 | text: `option ${i + 1}`, 14 | position: i + 1, 15 | })) 16 | 17 | const basePoll: Poll = { 18 | created_at: new Date(), 19 | id: '1', 20 | is_anonymous: true, 21 | is_public: false, 22 | source: testClient, 23 | max_options: 1, 24 | type: 'net.unsweets.beta', 25 | you_responded: false, 26 | user: fixtures('user'), 27 | closed_at: nextWeek, 28 | options, 29 | poll_token: 'poll_token', 30 | prompt: 'prompt message', 31 | } 32 | 33 | export default basePoll 34 | 35 | export const detail = { 36 | created_at: now, 37 | is_public: true, 38 | is_anonymous: false, 39 | id: '1', 40 | } 41 | 42 | export const closed = { 43 | closed_at: oneDayAgo, 44 | } 45 | 46 | export const responded = { 47 | you_responded: true, 48 | options: options.map((option, i) => { 49 | option.is_your_response = !i 50 | option.respondents = +!i 51 | return option 52 | }), 53 | } 54 | -------------------------------------------------------------------------------- /__tests__/setupAfter.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | -------------------------------------------------------------------------------- /_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 2 | -------------------------------------------------------------------------------- /example.env: -------------------------------------------------------------------------------- 1 | CLIENT_ID= 2 | HOST= 3 | PORT= 4 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'jsdom', 4 | moduleFileExtensions: ['ts', 'js', 'json', 'vue', 'svg'], 5 | transform: { 6 | '.*\\.(vue)$': 'vue-jest', 7 | '^.+\\.ts$': 'ts-jest', 8 | // 'typed-vuex': 'ts-jest', 9 | '^.+\\.js$': 'babel-jest', 10 | '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 11 | 'jest-transform-stub', 12 | }, 13 | moduleNameMapper: { 14 | '^[@~]/(.*)$': '/src/$1', 15 | '^helper$': '/__tests__/helper', 16 | '\\.css$': '/__mocks__/styleMock.ts', 17 | }, 18 | setupFiles: ['jest-localstorage-mock', '/__tests__/setup.ts'], 19 | setupFilesAfterEnv: ['/__tests__/setupAfter.ts'], 20 | testRegex: '(/__tests__/(components|pages)/.*|(\\.|/)(test|spec))\\.[jt]sx?$', 21 | // transformIgnorePatterns: ['typed-vuex'], 22 | transformIgnorePatterns: [], 23 | collectCoverageFrom: [ 24 | 'src/assets/ts/**/*', 25 | 'src/components/**/*', 26 | 'src/store/**/*', 27 | 'src/assets/ts/**/*', 28 | 'src/layouts/js/**/*', 29 | 'src/pages/js/**/*', 30 | ], 31 | } 32 | -------------------------------------------------------------------------------- /regconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "core": { 3 | "workingDir": ".reg", 4 | "actualDir": "__screenshots__", 5 | "thresholdRate": 0.5, 6 | "addIgnore": true, 7 | "ximgdiff": { 8 | "invocationType": "client" 9 | } 10 | }, 11 | "plugins": { 12 | "reg-keygen-git-hash-plugin": true, 13 | "reg-notify-github-plugin": { 14 | "clientId": "s7AwsjA2NDLUT0otSdS3NDYzMjc21i8uzatMtAQA" 15 | }, 16 | "reg-publish-gcs-plugin": { 17 | "bucketName": "reg-publish-bucket-fb32b727-eba6-4cb3-96fb-977fe62f48f5" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "travis": { 4 | "enabled": true 5 | }, 6 | "automerge": true, 7 | "major": { 8 | "automerge": false 9 | }, 10 | "reviewers": ["@sunya9"], 11 | "assignees": ["@sunya9"], 12 | "schedule": ["every weekend"] 13 | } 14 | -------------------------------------------------------------------------------- /src/assets/css/_mixin.scss: -------------------------------------------------------------------------------- 1 | @import 'bootstrap/scss/functions'; 2 | @import 'bootstrap/scss/variables'; 3 | @import 'bootstrap/scss/mixins'; 4 | 5 | @mixin no-gutter-xs { 6 | @include media-breakpoint-down(xs) { 7 | margin-left: -$grid-gutter-width / 2; 8 | margin-right: -$grid-gutter-width / 2; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/assets/ts/actionable.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { Model, Prop, Component } from 'nuxt-property-decorator' 3 | 4 | @Component({}) 5 | class Actionable extends Vue { 6 | @Model('change', { 7 | default: false, 8 | type: Boolean, 9 | }) 10 | checked!: boolean 11 | 12 | @Prop({ 13 | type: String, 14 | required: false, 15 | default: '', 16 | }) 17 | resource!: string 18 | 19 | processing: Promise | null = null 20 | get method() { 21 | return this.checked ? 'delete' : 'put' 22 | } 23 | 24 | // Don't use the way to watch `checked`. 25 | // If you use watch, might to occur infinite loops when revert checked state. 26 | async change(newVal: boolean) { 27 | this.$emit('change', newVal) 28 | if (!this.resource) return 29 | const processing = this.$axios(this.resource, { 30 | method: this.method, 31 | }) 32 | const old = this.checked 33 | this.processing = processing 34 | await processing.catch(() => { 35 | this.$emit('change', old) 36 | }) 37 | this.processing = null 38 | } 39 | 40 | toggle() { 41 | this.change(!this.checked) 42 | } 43 | } 44 | 45 | export const actionable = Actionable 46 | -------------------------------------------------------------------------------- /src/assets/ts/bus.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | export default new Vue() 4 | -------------------------------------------------------------------------------- /src/assets/ts/key-binding/default-mixin.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { KeyMap } from '~/assets/ts/key-binding' 3 | 4 | // FIXME 5 | function hasFunction( 6 | vue: Vue, 7 | method: string 8 | ): vue is Vue & { [key: string]: () => void } { 9 | return method in vue 10 | } 11 | export const defaultMixin = (keyMap: KeyMap) => 12 | Vue.extend({ 13 | async mounted() { 14 | await this.$nextTick() 15 | Object.keys(keyMap).forEach((key) => 16 | this.$mousetrap.bind(key, () => { 17 | const method = keyMap[key] 18 | if (hasFunction(this, method)) this[method]() 19 | else this.$emit(method) 20 | }) 21 | ) 22 | }, 23 | beforeDestroy() { 24 | Object.keys(keyMap).forEach((key) => this.$mousetrap.unbind(key)) 25 | }, 26 | }) 27 | -------------------------------------------------------------------------------- /src/assets/ts/key-binding/for-list.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { KeyMap } from '~/assets/ts/key-binding' 3 | 4 | export const forList = (keyMap: KeyMap, selector?: string) => 5 | Vue.extend({ 6 | data() { 7 | return { 8 | select: -1, 9 | } 10 | }, 11 | async mounted() { 12 | await this.$nextTick() 13 | const keys = Object.keys(keyMap) 14 | keys.forEach((key) => { 15 | const method = keyMap[key] 16 | this.$on(method, () => { 17 | if (this.select < 0 || !this.$el || !this.$el.children) return 18 | const el = selector 19 | ? this.$el.querySelector(selector) 20 | : this.$el.firstChild 21 | // @ts-ignore 22 | el.children[this.select].__vue__[method]() 23 | }) 24 | this.$once('hook:beforeDestroy', () => this.$off(method)) 25 | }) 26 | }, 27 | }) 28 | -------------------------------------------------------------------------------- /src/assets/ts/key-binding/index.ts: -------------------------------------------------------------------------------- 1 | import { defaultMixin } from './default-mixin' 2 | 3 | export type KeyMap = Record 4 | 5 | export default defaultMixin 6 | export { forList } from './for-list' 7 | -------------------------------------------------------------------------------- /src/assets/ts/list-item.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | export default (dateKey: string) => 4 | Vue.extend({ 5 | props: { 6 | lastUpdate: { 7 | type: Number, 8 | default: null, 9 | }, 10 | selected: { 11 | type: Boolean, 12 | default: false, 13 | }, 14 | }, 15 | computed: { 16 | itemDate(): Date { 17 | // TODO 18 | return dateKey.split('.').reduce((obj, key) => obj[key], this as any) 19 | }, 20 | date(): string { 21 | const now = this.$dayjs(this.lastUpdate) 22 | const postDate = this.$dayjs(this.itemDate) 23 | if (now.diff(postDate, 'day') >= 1) { 24 | const lastYear = 25 | now.toDate().getFullYear() - postDate.toDate().getFullYear() 26 | const format = lastYear ? 'D MMM YY' : 'D MMM' 27 | return postDate.format(format) 28 | } else { 29 | return postDate.fromNow(true) 30 | } 31 | }, 32 | absDate(): string { 33 | return this.$dayjs(this.itemDate).format('YYYY/MM/DD HH:mm:ss') 34 | }, 35 | }, 36 | }) 37 | -------------------------------------------------------------------------------- /src/assets/ts/oembed.ts: -------------------------------------------------------------------------------- 1 | import { OEmbed } from '~/entity/raw/raw/oembed' 2 | 3 | const youtube = { 4 | regexp: 5 | /https?:\/\/(?:(?:www)?\.youtube\.com\/watch\?v=|youtu\.be\/)([A-Za-z0-9\-_]+)/g, 6 | html: (id: string) => 7 | ``, 8 | 9 | // { provider_name: String, provider_url: String, regex: RegExp, html: function(id) } 10 | } 11 | 12 | const detectGenerators = [youtube] 13 | 14 | type ResObj = { html: string; embeddable_url: string } 15 | export function createVideoEmbedRaw(text: string): OEmbed.Video[] { 16 | return detectGenerators 17 | .reduce((rawHtmls, detectGenerator) => { 18 | let matcher 19 | while ((matcher = detectGenerator.regexp.exec(text)) !== null) { 20 | const [embeddable_url, id] = matcher 21 | rawHtmls.push({ 22 | html: detectGenerator.html(id), 23 | embeddable_url, 24 | }) 25 | } 26 | return rawHtmls 27 | }, []) 28 | .map((resObj) => { 29 | const { html, embeddable_url } = resObj 30 | return { 31 | type: 'io.pnut.core.oembed', 32 | value: { 33 | version: '1.0', 34 | type: 'video', 35 | width: 480, 36 | height: 270, 37 | html, 38 | embeddable_url, 39 | }, 40 | } 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /src/assets/ts/refresh-after-added.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import bus from '~/assets/ts/bus' 3 | 4 | export default Vue.extend({ 5 | data() { 6 | return { 7 | date: Date.now(), 8 | } 9 | }, 10 | mounted() { 11 | bus.$on('post', this.add) 12 | }, 13 | beforeDestroy() { 14 | bus.$off('post', this.add) 15 | }, 16 | methods: { 17 | add() { 18 | this.date = Date.now() 19 | }, 20 | }, 21 | }) 22 | -------------------------------------------------------------------------------- /src/assets/ts/resettable.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | export default Vue.extend({ 4 | methods: { 5 | reset() { 6 | const functionData = 7 | typeof this.$options.data === 'function' ? this.$options.data : null 8 | if (!functionData) return 9 | Object.assign(this.$data, functionData.apply(this)) 10 | }, 11 | }, 12 | }) 13 | -------------------------------------------------------------------------------- /src/assets/ts/search.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { Component } from 'vue-property-decorator' 3 | 4 | @Component({ 5 | watchQuery: true, 6 | key: (to) => to.fullPath, 7 | }) 8 | export class Search extends Vue { 9 | readonly keyword!: string 10 | readonly title!: string 11 | } 12 | -------------------------------------------------------------------------------- /src/assets/ts/text-count.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | // import TextCountWorker from '~/assets/ts/workers/text-count.worker' 3 | import stringLength from 'string-length' 4 | import _ from 'lodash' 5 | import { Component, Watch } from 'nuxt-property-decorator' 6 | 7 | @Component({}) 8 | export default class TextCount extends Vue { 9 | text = '' 10 | textLength = 0 11 | 12 | @Watch('text', { immediate: true }) 13 | debounceCalcTextLength = _.debounce(this.calcTextLength, 300) 14 | 15 | calcTextLength() { 16 | // http://stackoverflow.com/a/32382702 17 | const stripMarked = this.text.replace( 18 | /(\[((?:\[[^\]]*\]|[^[\]])*)\]\([ \t]*((?:\([^)]*\)|[^()\s])*?)([ \t]*)((['"])(.*?)\6[ \t]*)?\))/g, 19 | '[$2]' 20 | ) 21 | const length = stringLength(stripMarked) 22 | this.textLength = length 23 | return length 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/components/atoms/AclSelect.stories.ts: -------------------------------------------------------------------------------- 1 | import { withKnobs, select, boolean } from '@storybook/addon-knobs' 2 | import AclSelect from './AclSelect.vue' 3 | import { loginAs } from '~/fixtures/accessor' 4 | import { myselfEntity } from '~/fixtures/user' 5 | import { User } from '~/entity/user' 6 | export default { title: 'atoms/AclSelect', decorators: [withKnobs] } 7 | 8 | function base(user?: User) { 9 | loginAs(user) 10 | return { 11 | components: { AclSelect }, 12 | } 13 | } 14 | 15 | export const normal = () => ({ 16 | ...base(myselfEntity), 17 | props: { 18 | permission: { 19 | type: String, 20 | default: select('permission', ['read', 'write', 'full'], 'read'), 21 | }, 22 | anyUserWrite: { 23 | type: Boolean, 24 | default: boolean('anyUserWrite', false), 25 | }, 26 | }, 27 | template: '', 28 | }) 29 | -------------------------------------------------------------------------------- /src/components/atoms/ActionButton.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 47 | 48 | 55 | -------------------------------------------------------------------------------- /src/components/atoms/Avatar.stories.ts: -------------------------------------------------------------------------------- 1 | import { withKnobs, select } from '@storybook/addon-knobs' 2 | import Avatar from './Avatar.vue' 3 | export default { title: 'atoms/Avatar', decorators: [withKnobs] } 4 | 5 | const base = { 6 | components: { Avatar }, 7 | } 8 | 9 | export const normal = () => ({ 10 | ...base, 11 | props: { 12 | size: { 13 | type: Number, 14 | default: select('size', [0, 16, 24, 32, 64, 96], 24), 15 | }, 16 | }, 17 | template: '', 18 | }) 19 | 20 | export const placeholder = () => ({ 21 | ...base, 22 | template: 23 | '', 24 | }) 25 | 26 | export const userImage = () => ({ 27 | ...base, 28 | template: ``, 29 | }) 30 | -------------------------------------------------------------------------------- /src/components/atoms/BlockButton.vue: -------------------------------------------------------------------------------- 1 | 4 | 38 | -------------------------------------------------------------------------------- /src/components/atoms/CustomCheckbox.stories.ts: -------------------------------------------------------------------------------- 1 | import { withKnobs, boolean } from '@storybook/addon-knobs' 2 | import CustomCheckbox from './CustomCheckbox.vue' 3 | export default { title: 'atoms/CustomCheckbox', decorators: [withKnobs] } 4 | 5 | const base = { 6 | components: { CustomCheckbox }, 7 | } 8 | 9 | export const normal = () => ({ 10 | ...base, 11 | props: { 12 | value: { 13 | type: Boolean, 14 | required: true, 15 | default: boolean('check', false), 16 | }, 17 | }, 18 | template: 'text', 19 | }) 20 | 21 | export const checked = () => ({ 22 | ...base, 23 | props: { 24 | value: { 25 | type: Boolean, 26 | required: true, 27 | default: boolean('check', true), 28 | }, 29 | }, 30 | template: 'text', 31 | }) 32 | 33 | export const disabled = () => ({ 34 | ...base, 35 | props: { 36 | value: { 37 | type: Boolean, 38 | required: true, 39 | default: boolean('check', false), 40 | }, 41 | }, 42 | template: 'text', 43 | }) 44 | -------------------------------------------------------------------------------- /src/components/atoms/CustomCheckbox.vue: -------------------------------------------------------------------------------- 1 | 17 | 36 | -------------------------------------------------------------------------------- /src/components/atoms/EmojiButton.vue: -------------------------------------------------------------------------------- 1 | 6 | 12 | 24 | -------------------------------------------------------------------------------- /src/components/atoms/FollowButton.stories.ts: -------------------------------------------------------------------------------- 1 | import { withKnobs } from '@storybook/addon-knobs' 2 | import FollowButton from './FollowButton.vue' 3 | import { User } from '~/entity/user' 4 | import { loginAs } from '~/fixtures/accessor' 5 | import { myselfEntity, user } from '~/fixtures/user' 6 | 7 | export default { title: 'atoms/FollowButton', decorators: [withKnobs] } 8 | 9 | const base = (user?: User) => { 10 | loginAs(user) 11 | return { 12 | components: { FollowButton }, 13 | } 14 | } 15 | 16 | const profile = user.build() 17 | 18 | export const normal = () => ({ 19 | ...base(myselfEntity), 20 | data() { 21 | return { 22 | profile, 23 | } 24 | }, 25 | template: '', 26 | }) 27 | 28 | const followingUser: User = { 29 | ...profile, 30 | you_follow: true, 31 | } 32 | 33 | export const following = () => ({ 34 | ...base(myselfEntity), 35 | data() { 36 | return { 37 | profile: followingUser, 38 | } 39 | }, 40 | template: '', 41 | }) 42 | 43 | const blockedUser: User = { 44 | ...profile, 45 | you_blocked: true, 46 | } 47 | 48 | export const blocked = () => ({ 49 | ...base(myselfEntity), 50 | data() { 51 | return { 52 | profile: blockedUser, 53 | } 54 | }, 55 | template: '', 56 | }) 57 | -------------------------------------------------------------------------------- /src/components/atoms/MuteButton.stories.ts: -------------------------------------------------------------------------------- 1 | import { withKnobs } from '@storybook/addon-knobs' 2 | import MuteButton from './MuteButton.vue' 3 | import { getUserFixture } from '~/fixtures' 4 | import { User } from '~/entity/user' 5 | 6 | export default { title: 'atoms/MuteButton', decorators: [withKnobs] } 7 | 8 | const base = { 9 | components: { MuteButton }, 10 | } 11 | 12 | const profile: User = getUserFixture() 13 | 14 | export const normal = () => ({ 15 | ...base, 16 | data() { 17 | return { 18 | profile, 19 | } 20 | }, 21 | template: '', 22 | }) 23 | 24 | const mutedUser: User = { 25 | ...profile, 26 | you_muted: true, 27 | } 28 | 29 | export const enabled = () => ({ 30 | ...base, 31 | data() { 32 | return { 33 | profile: mutedUser, 34 | } 35 | }, 36 | template: '', 37 | }) 38 | -------------------------------------------------------------------------------- /src/components/atoms/MuteButton.vue: -------------------------------------------------------------------------------- 1 | 10 | 49 | -------------------------------------------------------------------------------- /src/components/atoms/NuxtLinkMod.vue: -------------------------------------------------------------------------------- 1 | 9 | 30 | -------------------------------------------------------------------------------- /src/components/atoms/RelationBadge.stories.ts: -------------------------------------------------------------------------------- 1 | import RelationBadge from './RelationBadge.vue' 2 | import { getUserFixture } from '~/fixtures' 3 | import { User } from '~/entity/user' 4 | import { loginAs } from '~/fixtures/accessor' 5 | import { myselfEntity } from '~/fixtures/user' 6 | 7 | export default { title: 'atoms/RelationBadge' } 8 | 9 | const base = (user?: User) => { 10 | loginAs(user) 11 | return { 12 | components: { RelationBadge }, 13 | } 14 | } 15 | 16 | const profile: User = getUserFixture({ 17 | you_can_follow: true, 18 | }) 19 | 20 | export const normal = () => ({ 21 | ...base(), 22 | data() { 23 | return { 24 | profile, 25 | } 26 | }, 27 | template: '', 28 | }) 29 | 30 | const notFollowedUser: User = getUserFixture({ 31 | follows_you: false, 32 | }) 33 | 34 | export const notFollowed = () => ({ 35 | ...base(), 36 | data() { 37 | return { 38 | profile: notFollowedUser, 39 | } 40 | }, 41 | template: '', 42 | }) 43 | 44 | const me: User = getUserFixture({ 45 | follows_you: false, 46 | }) 47 | 48 | export const myself = () => ({ 49 | ...base(myselfEntity), 50 | data() { 51 | return { 52 | me, 53 | } 54 | }, 55 | template: '', 56 | }) 57 | -------------------------------------------------------------------------------- /src/components/atoms/RelationBadge.vue: -------------------------------------------------------------------------------- 1 | 10 | 36 | -------------------------------------------------------------------------------- /src/components/atoms/SourceLink.vue: -------------------------------------------------------------------------------- 1 | 4 | 18 | -------------------------------------------------------------------------------- /src/components/atoms/ThumbnailImage.vue: -------------------------------------------------------------------------------- 1 | 4 | 10 | 18 | -------------------------------------------------------------------------------- /src/components/atoms/ToggleButton.stories.ts: -------------------------------------------------------------------------------- 1 | import { boolean, withKnobs } from '@storybook/addon-knobs' 2 | import ToggleButton from './ToggleButton.vue' 3 | import { User } from '~/entity/user' 4 | import { loginAs } from '~/fixtures/accessor' 5 | 6 | export default { title: 'atoms/ToggleButton', decorators: [withKnobs] } 7 | 8 | const base = (user?: User) => { 9 | loginAs(user) 10 | return { 11 | components: { ToggleButton }, 12 | } 13 | } 14 | 15 | export const normal = () => ({ 16 | ...base(), 17 | data() { 18 | return { 19 | value: boolean('check', false), 20 | } 21 | }, 22 | template: '', 23 | }) 24 | 25 | export const active = () => ({ 26 | ...base(), 27 | data() { 28 | return { 29 | value: boolean('check', true), 30 | } 31 | }, 32 | template: '', 33 | }) 34 | 35 | export const disabled = () => ({ 36 | ...base(), 37 | data() { 38 | return { 39 | value: boolean('check', false), 40 | } 41 | }, 42 | template: 43 | '', 44 | }) 45 | 46 | export const activeDisabled = () => ({ 47 | ...base(), 48 | data() { 49 | return { 50 | value: boolean('check', true), 51 | } 52 | }, 53 | template: 54 | '', 55 | }) 56 | -------------------------------------------------------------------------------- /src/components/atoms/ToggleButton.vue: -------------------------------------------------------------------------------- 1 | 19 | 36 | -------------------------------------------------------------------------------- /src/components/atoms/ToggleLongpost.vue: -------------------------------------------------------------------------------- 1 | 4 | 22 | -------------------------------------------------------------------------------- /src/components/atoms/ToggleNsfw.vue: -------------------------------------------------------------------------------- 1 | 9 | 20 | -------------------------------------------------------------------------------- /src/components/atoms/TogglePoll.vue: -------------------------------------------------------------------------------- 1 | 9 | 27 | -------------------------------------------------------------------------------- /src/components/atoms/ToggleSpoiler.vue: -------------------------------------------------------------------------------- 1 | 9 | 27 | -------------------------------------------------------------------------------- /src/components/layouts/channel.vue: -------------------------------------------------------------------------------- 1 | 31 | 41 | -------------------------------------------------------------------------------- /src/components/molecules/AppDropdown.vue: -------------------------------------------------------------------------------- 1 | 16 | 42 | -------------------------------------------------------------------------------- /src/components/molecules/BaseChannelPanel.ts: -------------------------------------------------------------------------------- 1 | import Vue, { PropOptions } from 'vue' 2 | import { Channel } from '~/entity/channel' 3 | 4 | export const BaseChannelPanel = Vue.extend({ 5 | props: { 6 | initialChannel: { 7 | type: Object, 8 | required: true, 9 | } as PropOptions, 10 | }, 11 | data() { 12 | return { 13 | channel: this.initialChannel, 14 | } 15 | }, 16 | watch: { 17 | channel: { 18 | handler(channel) { 19 | this.$emit('update:initialChannel', channel) 20 | }, 21 | immediate: true, 22 | deep: true, 23 | }, 24 | }, 25 | }) 26 | -------------------------------------------------------------------------------- /src/components/molecules/FilePreview/FilePreviewAbstract.ts: -------------------------------------------------------------------------------- 1 | import { Vue, Component, Prop } from 'vue-property-decorator' 2 | 3 | @Component 4 | export class FilePreviewAbstract extends Vue { 5 | @Prop({ type: String, required: true }) 6 | objectUrl!: string 7 | } 8 | -------------------------------------------------------------------------------- /src/components/molecules/FilePreview/FilePreviewAudio.vue: -------------------------------------------------------------------------------- 1 | 15 | 26 | 31 | -------------------------------------------------------------------------------- /src/components/molecules/FilePreview/FilePreviewImage.vue: -------------------------------------------------------------------------------- 1 | 4 | 26 | -------------------------------------------------------------------------------- /src/components/molecules/FileRow.stories.ts: -------------------------------------------------------------------------------- 1 | import Router from 'vue-router' 2 | import FileRow from './FileRow.vue' 3 | import { File } from '~/entity/file' 4 | import { DeepPartial } from '~/../types' 5 | export default { title: 'molecules/FileRow' } 6 | 7 | function base() { 8 | return { 9 | components: { FileRow }, 10 | router: new Router(), 11 | } 12 | } 13 | 14 | const file: DeepPartial = { 15 | name: 'file', 16 | created_at: new Date('2020-01-01'), 17 | image_info: {}, 18 | link: 'https://via.placeholder.com/256', 19 | } 20 | 21 | export const normal = () => ({ 22 | ...base(), 23 | props: { 24 | file: { 25 | default: () => file, 26 | }, 27 | }, 28 | template: ` 29 |
30 | 31 | 34 |
35 |
36 | `, 37 | }) 38 | -------------------------------------------------------------------------------- /src/components/molecules/Sound.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 44 | 45 | 59 | -------------------------------------------------------------------------------- /src/components/molecules/Splash.vue: -------------------------------------------------------------------------------- 1 | 21 | 25 | -------------------------------------------------------------------------------- /src/components/molecules/UserPopper.vue: -------------------------------------------------------------------------------- 1 | 14 | 39 | 46 | -------------------------------------------------------------------------------- /src/components/molecules/UserPopperInner.stories.ts: -------------------------------------------------------------------------------- 1 | import Router from 'vue-router' 2 | import UserPopperInner from './UserPopperInner.vue' 3 | import { getUserFixture } from '~/fixtures' 4 | import { User } from '~/entity/user' 5 | import { loginAs } from '~/fixtures/accessor' 6 | 7 | export default { title: 'molecules/UserPopperInner' } 8 | 9 | function base(user?: User) { 10 | loginAs(user) 11 | return { 12 | components: { UserPopperInner }, 13 | router: new Router(), 14 | } 15 | } 16 | 17 | export const normal = () => { 18 | return { 19 | ...base(), 20 | props: { 21 | user: { 22 | type: Object, 23 | default: () => getUserFixture(), 24 | }, 25 | }, 26 | template: '', 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/components/molecules/settings/Display.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 45 | -------------------------------------------------------------------------------- /src/components/molecules/settings/Stream.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 37 | -------------------------------------------------------------------------------- /src/components/organisms/ChannelList.vue: -------------------------------------------------------------------------------- 1 | 20 | 57 | -------------------------------------------------------------------------------- /src/components/organisms/Compose.stories.ts: -------------------------------------------------------------------------------- 1 | import { withKnobs } from '@storybook/addon-knobs' 2 | import Compose from './Compose.vue' 3 | import { loginAs } from '~/fixtures/accessor' 4 | import { User } from '~/entity/user' 5 | 6 | export default { title: 'organisms/Compose', decorators: [withKnobs] } 7 | 8 | const base = (user?: User) => { 9 | loginAs(user) 10 | return { 11 | components: { Compose }, 12 | } 13 | } 14 | 15 | export const normal = () => ({ 16 | ...base(), 17 | template: '', 18 | }) 19 | -------------------------------------------------------------------------------- /src/components/organisms/CreatePmModal.vue: -------------------------------------------------------------------------------- 1 | 20 | 50 | -------------------------------------------------------------------------------- /src/components/organisms/FilePreviewList.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 33 | 49 | -------------------------------------------------------------------------------- /src/components/organisms/Header.stories.ts: -------------------------------------------------------------------------------- 1 | import Router from 'vue-router' 2 | import Header from './Header.vue' 3 | import { loginAs } from '~/fixtures/accessor' 4 | import { User } from '~/entity/user' 5 | import { myselfEntity } from '~/fixtures/user' 6 | export default { title: 'organisms/Header', decorators: [] } 7 | 8 | const base = (user?: User) => { 9 | loginAs(user) 10 | 11 | return { 12 | components: { 13 | Header, 14 | }, 15 | router: new Router(), 16 | } 17 | } 18 | 19 | export const notLoggedIn = () => ({ 20 | ...base(), 21 | template: '
', 22 | }) 23 | 24 | export const loggedIn = () => ({ 25 | ...base(myselfEntity), 26 | template: '
', 27 | }) 28 | -------------------------------------------------------------------------------- /src/components/organisms/HelpModal.stories.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Router from 'vue-router' 3 | import HelpModal from './HelpModal.vue' 4 | 5 | export default { title: 'organisms/HelpModal' } 6 | 7 | function base() { 8 | return { 9 | components: { HelpModal }, 10 | } 11 | } 12 | 13 | const router = new Router() 14 | 15 | export const normal = () => 16 | Vue.extend({ 17 | ...base(), 18 | mounted() { 19 | this.$modal.show('help-modal') 20 | this.$el.querySelector('.modal')?.classList.remove('fade') 21 | }, 22 | beforeDestroy() { 23 | this.$modal.ok('help-modal') 24 | }, 25 | router, 26 | template: '', 27 | }) 28 | -------------------------------------------------------------------------------- /src/components/organisms/InputLongpost.vue: -------------------------------------------------------------------------------- 1 |