Compare commits
No commits in common. "main" and "v0.7.2" have entirely different histories.
585 changed files with 19653 additions and 48746 deletions
|
@ -11,6 +11,7 @@ dist
|
|||
.netlify/
|
||||
.eslintcache
|
||||
|
||||
public/shiki
|
||||
public/emojis
|
||||
|
||||
*~
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
NUXT_PUBLIC_TRANSLATE_API=
|
||||
NUXT_PUBLIC_DEFAULT_SERVER=
|
||||
NUXT_PUBLIC_SINGLE_INSTANCE=
|
||||
NUXT_PUBLIC_PRIVACY_POLICY_URL=
|
||||
|
||||
# Production only
|
||||
|
@ -8,7 +7,7 @@ NUXT_CLOUDFLARE_ACCOUNT_ID=
|
|||
NUXT_CLOUDFLARE_NAMESPACE_ID=
|
||||
NUXT_CLOUDFLARE_API_TOKEN=
|
||||
|
||||
# 'cloudflare' | 'vercel' | 'fs'
|
||||
# 'cloudflare' | 'fs'
|
||||
NUXT_STORAGE_DRIVER=
|
||||
NUXT_STORAGE_FS_BASE=
|
||||
|
||||
|
|
11
.eslintignore
Normal file
11
.eslintignore
Normal file
|
@ -0,0 +1,11 @@
|
|||
*.css
|
||||
*.png
|
||||
*.ico
|
||||
*.toml
|
||||
*.patch
|
||||
*.txt
|
||||
Dockerfile
|
||||
public/
|
||||
https-dev-config/localhost.crt
|
||||
https-dev-config/localhost.key
|
||||
Dockerfile
|
18
.eslintrc
Normal file
18
.eslintrc
Normal file
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"extends": "@antfu",
|
||||
"ignorePatterns": ["!pages/public"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["locales/**.json"],
|
||||
"rules": {
|
||||
"jsonc/sort-keys": "error"
|
||||
}
|
||||
}
|
||||
],
|
||||
"rules": {
|
||||
"vue/no-restricted-syntax":["error", {
|
||||
"selector": "VElement[name='a']",
|
||||
"message": "Use NuxtLink instead."
|
||||
}]
|
||||
}
|
||||
}
|
1
.gitattributes
vendored
1
.gitattributes
vendored
|
@ -1 +0,0 @@
|
|||
* text=auto eol=lf
|
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
|
@ -2,4 +2,4 @@
|
|||
name: 🚀 New feature proposal
|
||||
about: Propose a new feature
|
||||
labels: 's: pending triage'
|
||||
---
|
||||
---
|
13
.github/renovate.json5
vendored
13
.github/renovate.json5
vendored
|
@ -3,14 +3,6 @@
|
|||
"extends": ["config:base", "schedule:weekly", "group:allNonMajor"],
|
||||
"labels": ["c: dependencies"],
|
||||
"rangeStrategy": "bump",
|
||||
"ignoreDeps": [
|
||||
"vue",
|
||||
"vue-tsc",
|
||||
"typescript",
|
||||
|
||||
// Intl.Segmenter is not supported in Firefox
|
||||
"string-length"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"groupName": "devDependencies",
|
||||
|
@ -40,6 +32,7 @@
|
|||
"groupName": "lint",
|
||||
"matchPackageNames": [
|
||||
"@antfu/eslint-config",
|
||||
"@types/prettier",
|
||||
"eslint",
|
||||
"prettier"
|
||||
]
|
||||
|
@ -63,10 +56,6 @@
|
|||
{
|
||||
"groupName": "typescript",
|
||||
"matchPackageNames": ["typescript"]
|
||||
},
|
||||
{
|
||||
"matchDatasources": ["node-version"],
|
||||
"enabled": false
|
||||
}
|
||||
],
|
||||
"vulnerabilityAlerts": {
|
||||
|
|
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
|
@ -10,21 +10,18 @@ on:
|
|||
branches:
|
||||
- main
|
||||
workflow_dispatch: {}
|
||||
merge_group: {}
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# workaround for npm registry key change
|
||||
# ref. `pnpm@10.1.0` / `pnpm@9.15.4` cannot be installed due to key id mismatch · Issue #612 · nodejs/corepack
|
||||
# - https://github.com/nodejs/corepack/issues/612#issuecomment-2629496091
|
||||
- run: npm i -g corepack@latest && corepack enable
|
||||
- uses: actions/setup-node@v4.4.0
|
||||
- uses: actions/checkout@v3
|
||||
- run: corepack enable
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
node-version: 18
|
||||
cache: pnpm
|
||||
|
||||
- name: 📦 Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
@ -33,8 +30,7 @@ jobs:
|
|||
run: pnpm nuxi prepare
|
||||
|
||||
- name: 🧪 Test project
|
||||
run: pnpm test:ci
|
||||
timeout-minutes: 10
|
||||
run: pnpm test
|
||||
|
||||
- name: 📝 Lint
|
||||
run: pnpm lint
|
||||
|
|
20
.github/workflows/docker.yml
vendored
20
.github/workflows/docker.yml
vendored
|
@ -16,29 +16,29 @@ jobs:
|
|||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v3
|
||||
- name: Docker meta
|
||||
id: metal
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository }}
|
||||
ghcr.io/elk-zone/elk
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.metal.outputs.tags }}
|
||||
labels: ${{ steps.metal.outputs.labels }}
|
||||
|
|
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
@ -12,14 +12,14 @@ jobs:
|
|||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
node-version: 18
|
||||
|
||||
- run: npx changelogithub
|
||||
env:
|
||||
|
|
2
.github/workflows/semantic-pull-request.yml
vendored
2
.github/workflows/semantic-pull-request.yml
vendored
|
@ -19,6 +19,6 @@ jobs:
|
|||
name: Semantic Pull Request
|
||||
steps:
|
||||
- name: Validate PR title
|
||||
uses: amannn/action-semantic-pull-request@v5.5.3
|
||||
uses: amannn/action-semantic-pull-request@v5.0.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -2,7 +2,6 @@ node_modules
|
|||
*.log
|
||||
dist
|
||||
.output
|
||||
.pnpm-store
|
||||
.nuxt
|
||||
.env
|
||||
.DS_Store
|
||||
|
@ -10,8 +9,8 @@ dist
|
|||
.vite-inspect
|
||||
.netlify/
|
||||
.eslintcache
|
||||
elk-translation-status.json
|
||||
|
||||
public/shiki
|
||||
public/emojis
|
||||
|
||||
*~
|
||||
|
|
2
.npmrc
2
.npmrc
|
@ -1,4 +1,4 @@
|
|||
shamefully-hoist=true
|
||||
strict-peer-dependencies=false
|
||||
shell-emulator=true
|
||||
ignore-workspace-root-check=true
|
||||
package-manager-strict=false
|
||||
|
|
1
.nvmrc
1
.nvmrc
|
@ -1 +0,0 @@
|
|||
22
|
47
.vscode/settings.json
vendored
47
.vscode/settings.json
vendored
|
@ -5,6 +5,10 @@
|
|||
"unmute",
|
||||
"unstorage"
|
||||
],
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
"editor.formatOnSave": false,
|
||||
"files.associations": {
|
||||
"*.css": "postcss"
|
||||
},
|
||||
|
@ -18,45 +22,8 @@
|
|||
],
|
||||
"i18n-ally.preferredDelimiter": "_",
|
||||
"i18n-ally.sortKeys": true,
|
||||
"i18n-ally.sourceLanguage": "en",
|
||||
|
||||
// Enable the ESlint flat config support
|
||||
"eslint.experimental.useFlatConfig": true,
|
||||
|
||||
// Disable the default formatter, use eslint instead
|
||||
"i18n-ally.sourceLanguage": "en-US",
|
||||
"prettier.enable": false,
|
||||
"editor.formatOnSave": false,
|
||||
|
||||
// Auto fix
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "never"
|
||||
},
|
||||
|
||||
// Silent the stylistic rules in you IDE, but still auto fix them
|
||||
"eslint.rules.customizations": [
|
||||
{ "rule": "style/*", "severity": "off" },
|
||||
{ "rule": "*-indent", "severity": "off" },
|
||||
{ "rule": "*-spacing", "severity": "off" },
|
||||
{ "rule": "*-spaces", "severity": "off" },
|
||||
{ "rule": "*-order", "severity": "off" },
|
||||
{ "rule": "*-dangle", "severity": "off" },
|
||||
{ "rule": "*-newline", "severity": "off" },
|
||||
{ "rule": "*quotes", "severity": "off" },
|
||||
{ "rule": "*semi", "severity": "off" }
|
||||
],
|
||||
|
||||
// Enable eslint for all supported languages
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact",
|
||||
"vue",
|
||||
"html",
|
||||
"markdown",
|
||||
"json",
|
||||
"jsonc",
|
||||
"yaml"
|
||||
]
|
||||
"volar.completion.preferredTagNameCase": "pascal",
|
||||
"volar.completion.preferredAttrNameCase": "kebab"
|
||||
}
|
||||
|
|
|
@ -1,45 +0,0 @@
|
|||
# Code Of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, political party, or sexual identity and orientation. Note, however, that religion, political party, or other ideological affiliation provide no exemptions for the behavior we outline as unacceptable in this Code of Conduct.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment include:
|
||||
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Gracefully accepting constructive criticism
|
||||
- Focusing on what is best for the community
|
||||
- Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
- The use of sexualized language or imagery and unwelcome sexual attention or advances
|
||||
- Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or electronic address, without explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team by DM at [the Elk Discord](https://chat.elk.zone). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
|
@ -1,10 +1,14 @@
|
|||
# Contributing Guide
|
||||
|
||||
Hi! We are excited that you are interested in contributing to Elk. Before submitting your contribution, please make sure to take a moment and read through the following guide.
|
||||
Hi! We are really excited that you are interested in contributing to Elk. Before submitting your contribution, please make sure to take a moment and read through the following guide.
|
||||
|
||||
Refer also to https://github.com/antfu/contribute.
|
||||
|
||||
For guidelines on contributing to the documentation, refer to the [docs README](./docs/README.md).
|
||||
### Online
|
||||
|
||||
You can use [StackBlitz Codeflow](https://stackblitz.com/codeflow) to fix bugs or implement features. You'll also see a Codeflow button on PRs to review them without a local setup. Once the elk repo has been cloned in Codeflow, the dev server will start automatically and print the URL to open the App. You should receive a prompt in the bottom-right suggesting to open it in the Editor or in another Tab. To learn more, check out the [Codeflow docs](https://developer.stackblitz.com/codeflow/what-is-codeflow).
|
||||
|
||||
[](https://pr.new/elk-zone/elk)
|
||||
|
||||
### Local Setup
|
||||
|
||||
|
@ -12,10 +16,9 @@ To develop and test the Elk package:
|
|||
|
||||
1. Fork the Elk repository to your own GitHub account and then clone it to your local device.
|
||||
|
||||
2. Ensure using the LTS version of Node.js.
|
||||
If you have [nvm](https://github.com/nvm-sh/nvm), you can run `nvm i` to install the required version.
|
||||
2. Ensure using the latest Node.js (16.x)
|
||||
|
||||
3. The package manager used to install and link dependencies must be [pnpm](https://pnpm.io/) v9. To use it you must first enable [Corepack](https://github.com/nodejs/corepack) by running `corepack enable`. (Note: on Linux in a standard Node 20+ environment, you should follow the instructions to install via Node's `corepack` rather than using the `curl` command)
|
||||
3. The package manager used to install and link dependencies must be [pnpm](https://pnpm.io/) v7. To use it you must first enable [Corepack](https://github.com/nodejs/corepack) by running `corepack enable`. (Note: on Linux in a standard Node 16+ environment, you should follow the instructions to install via Node's `corepack` rather than using the `curl` command)
|
||||
|
||||
4. Check out a branch where you can work and commit your changes:
|
||||
```shell
|
||||
|
@ -47,16 +50,16 @@ nr test
|
|||
|
||||
In order to run Elk with PWA enabled, run `pnpm dev:pwa` in Elk's root folder to start dev server or `pnpm dev:mocked:pwa` to start dev server with `@elkdev@universeodon.com` user.
|
||||
|
||||
You should test the Elk PWA application on private browsing mode on any Chromium-based browser: will not work on Firefox and Safari.
|
||||
You should test the Elk PWA application on private browsing mode on any Chromium based browser: will not work on Firefox and Safari.
|
||||
|
||||
If not using private browsing mode, you will need to uninstall the PWA application from your browser once you finish testing:
|
||||
- Open `Dev Tools` (`Option + ⌘ + J` on macOS, `Shift + CTRL + J` on Windows/Linux)
|
||||
- Go to `Application > Storage`, you should check the following checkboxes:
|
||||
- Go to `Application > Storage`, you should check following checkboxes:
|
||||
- Application: [x] Unregister service worker
|
||||
- Storage: [x] IndexedDB and [x] Local and session storage
|
||||
- Cache: [x] Cache storage and [x] Application cache
|
||||
- Click on `Clear site data` button
|
||||
- Go to `Application > Service Workers` and check if the current `service worker` is missing or has the state `deleted` or `redundant`
|
||||
- Go to `Application > Service Workers` and check the current `service worker` is missing or has the state `deleted` or `redundant`
|
||||
|
||||
## CI errors
|
||||
|
||||
|
@ -75,30 +78,23 @@ Elk supports `right-to-left` languages, we need to make sure that the UI is work
|
|||
Simple approach used by most websites of relying on direction set in HTML element does not work because direction for various items, such as timeline, does not always match direction set in HTML.
|
||||
|
||||
We've added some `UnoCSS` utilities styles to help you with that:
|
||||
- Do not use `left/right` padding and margin: for example `pl-1`. Use `padding-inline-start/end` instead. So `pl-1` should be `ps-1`, `pr-1` should be `pe-1`. The same rules apply to margin.
|
||||
- Do not use `left/right` padding and margin: for example `pl-1`. Use `padding-inline-start/end` instead. So `pl-1` should be `ps-1`, `pr-1` should be `pe-1`. Same rules applies for margin.
|
||||
- Do not use `rtl-` classes, such as `rtl-left-0`.
|
||||
- For icons that should be rotated for RTL, add `class="rtl-flip"`. This can only be used for icons outside of elements with `dir="auto"`, such as timeline, and is the only exception to the rule above. For icons inside the timeline, it might not work as expected.
|
||||
- For icons that should be rotated for RTL, add `class="rtl-flip"`. This can only be used for icons outside of elements with `dir="auto"`, such as timeline, and is the only exception from rule above. For icons inside timeline it might not work as expected.
|
||||
- For absolute positioned elements, don't use `left/right`: for example `left-0`. Use `inset-inline-start/end` instead. `UnoCSS` shortcuts are `inset-is` for `inset-inline-start` and `inset-ie` for `inset-inline-end`. Example: `left-0` should be replaced with `inset-is-0`.
|
||||
- If you need to change the border radius for an entire left or right side, use `border-inline-start/end`. `UnoCSS` shortcuts are `rounded-is` for left side, `rounded-ie` for right side. Example: `rounded-l-5` should be replaced with `rounded-ie-5`.
|
||||
- If you need to change the border radius for one corner, use `border-start-end-radius` and similar rules. `UnoCSS` shortcuts are `rounded` + top/bottom as either `-bs` (top) or `-be` (bottom) + left/right as either `-is` (left) or `-ie` (right). Example: `rounded-tl-0` should be replaced with `rounded-bs-is-0`.
|
||||
- If you need to change border radius for an entire left or right side, use `border-inline-start/end`. `UnoCSS` shortcuts are `rounded-is` for left side, `rounded-ie` for right side. Example: `rounded-l-5` should be replaced with `rounded-ie-5`.
|
||||
- If you need to change border radius for one corner, use `border-start-end-radius` and similar rules. `UnoCSS` shortcuts are `rounded` + top/bottom as either `-bs` (top) or `-be` (bottom) + left/right as either `-is` (left) or `-ie` (right). Example: `rounded-tl-0` should be replaced with `rounded-bs-is-0`.
|
||||
|
||||
## Internationalization
|
||||
|
||||
We are using [vue-i18n](https://vue-i18n.intlify.dev/) via [nuxt-i18n](https://i18n.nuxtjs.org/) to handle internationalization.
|
||||
|
||||
You can check the current [translation status](https://docs.elk.zone/guide/contributing#translation-status): more instructions on the table caption.
|
||||
|
||||
If you are updating a translation in your local environment, you can run the following commands to check the status:
|
||||
- from root folder: `nr prepare-translation-status`
|
||||
- change to `docs` folder and run docs dev server `nr dev`
|
||||
- open `http://localhost:3000/guide/contributing#translation-status` in your browser
|
||||
|
||||
### Adding a new language
|
||||
|
||||
1. Add a new file in [locales](./locales) folder with the language code as the filename.
|
||||
2. Copy [en](./locales/en.json) and translate the strings.
|
||||
2. Copy [en-US](./locales/en-US.json) and translate the strings.
|
||||
3. Add the language to the `locales` array in [config/i18n.ts](./config/i18n.ts#L61), below `en` and `ar`:
|
||||
- If your language has multiple country variants, add the generic one for language only (only if there are a lot of common entries, you can always add it as a new one)
|
||||
- If your language have multiple country variants, add the generic one for language only (only if there are a lot of common entries, you can always add it as a new one)
|
||||
- Add all country variants in [country variants object](./config/i18n.ts#L12)
|
||||
- Add all country variants files with empty `messages` object: `{}`
|
||||
- Translate the strings in the generic language file
|
||||
|
@ -113,7 +109,7 @@ Check [Pluralization rule callback](https://vue-i18n.intlify.dev/guide/essential
|
|||
|
||||
### Messages interpolation
|
||||
|
||||
Most of the messages used in Elk do not require any interpolation, however, some messages require interpolation: check [Message Format Syntax](https://vue-i18n.intlify.dev/guide/essentials/syntax.html) for more info.
|
||||
Most of the messages used in Elk do not require any interpolation, however, there are some messages that require interpolation: check [Message Format Syntax](https://vue-i18n.intlify.dev/guide/essentials/syntax.html) for more info.
|
||||
|
||||
We're using these types of interpolation:
|
||||
- [List interpolation](https://vue-i18n.intlify.dev/guide/essentials/syntax.html#list-interpolation)
|
||||
|
@ -135,7 +131,7 @@ Check [Custom Plural Number Formatting Entries](#custom-plural-number-formatting
|
|||
|
||||
When using plural number formatting, we'll have always `{n}` available in the message, for example, `You have {n} new notifications|You have {n} new notification|You have {n} new notifications` or `You have no new notifications|You have 1 new notification|You have {n} new notifications`.
|
||||
|
||||
We've included `v` named parameter, it will be used to pass the formatted number using [Intl.NumberFormat::format](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/format): will be the number with separators symbols. The exception to the previous rule is when we're using `plural` **with** `i18n-t` component, in this case, we'll need to use `{0}` instead `{v}` to access the number.
|
||||
We've included `v` named parameter, it will be used to pass the formatted number using [Intl.NumberFormat::format](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/format): will be the number with separators symbols. The exception to previous rule is when we're using `plural` **with** `i18n-t` component, in this case, we'll need to use `{0}` instead `{v}` to access the number.
|
||||
|
||||
Additionally, Elk will use [compact notation for numbers](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#parameters) for some entries, check `notation` and `compactDisplay` options: for example, `1K` for `1000`, `1M` for `1000000`, `1B` for `1000000000` and so on. That entry will be available in the message using `{v}` named parameter (or `{0}` if using the message **with** `i18n-t` component).
|
||||
|
||||
|
|
10
Dockerfile
10
Dockerfile
|
@ -6,10 +6,7 @@ WORKDIR /elk
|
|||
FROM base AS builder
|
||||
|
||||
# Prepare pnpm https://pnpm.io/installation#using-corepack
|
||||
# workaround for npm registry key change
|
||||
# ref. `pnpm@10.1.0` / `pnpm@9.15.4` cannot be installed due to key id mismatch · Issue #612 · nodejs/corepack
|
||||
# - https://github.com/nodejs/corepack/issues/612#issuecomment-2629496091
|
||||
RUN npm i -g corepack@latest && corepack enable
|
||||
RUN corepack enable
|
||||
|
||||
# Prepare deps
|
||||
RUN apk update
|
||||
|
@ -17,7 +14,6 @@ RUN apk add git --no-cache
|
|||
|
||||
# Prepare build deps ( ignore postinstall scripts for now )
|
||||
COPY package.json ./
|
||||
COPY .npmrc ./
|
||||
COPY pnpm-lock.yaml ./
|
||||
COPY patches ./patches
|
||||
RUN pnpm i --frozen-lockfile --ignore-scripts
|
||||
|
@ -38,8 +34,8 @@ ARG GID=911
|
|||
|
||||
# Create a dedicated user and group
|
||||
RUN set -eux; \
|
||||
addgroup -g $GID elk; \
|
||||
adduser -u $UID -D -G elk elk;
|
||||
addgroup -g $UID elk; \
|
||||
adduser -u $GID -D -G elk elk;
|
||||
|
||||
USER elk
|
||||
|
||||
|
|
98
README.md
98
README.md
|
@ -1,16 +1,28 @@
|
|||
# Yolk
|
||||
<p align="center">
|
||||
<a href="https://elk.zone" target="_blank" rel="noopener noreferrer">
|
||||
<img width="160" height="160" src="./public/logo.svg" alt="Elk logo">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Hi! Yolk is my custom fork of [Elk](https://github.com/elk-zon/elk), a nimble Mastodon client.
|
||||
<h1 align="center"/>Elk <sup><em>alpha</em></sup></h1>
|
||||
|
||||
I [decided](https://social.ayco.io/@ayo/114921112446517000) to have a personal fork of Elk because I really like the cross-account functionalities I use it for (e.g., I can open the Explore tab of my fosstodon account, then engage in a post with my self-hosted account, etc)... but I find sometimes I want to change little things which will make the app a bit more opinionated on my tastes (e.g., icons, colors, spacing, etc)... and some behavioral features.
|
||||
<p align="center">
|
||||
A nimble Mastodon web client
|
||||
</p>
|
||||
|
||||
I think doing this will make me use it as my main app daily. I have been switching between multiple apps because each one have strengths & weaknesses of their own.
|
||||
<br/>
|
||||
<p align="center">
|
||||
<a href="https://chat.elk.zone"><img src="https://img.shields.io/badge/chat-discord-blue?style=flat&logo=discord" alt="discord chat"></a>
|
||||
<a href="https://pr.new/elk-zone/elk"><img src="https://developer.stackblitz.com/img/start_pr_dark_small.svg" alt="Start new PR in StackBlitz Codeflow"></a>
|
||||
<a href="https://volta.net/elk-zone/elk?utm_source=elk_readme"><img src="https://user-images.githubusercontent.com/904724/209143798-32345f6c-3cf8-4e06-9659-f4ace4a6acde.svg" alt="Open board on Volta"></a>
|
||||
</p>
|
||||
<br/>
|
||||
|
||||
Crucial fixes (if I find them), quality of life improvements, and mastodon API feature parity will still go upstream to the main Elk project.
|
||||
|
||||
~ Ayo Ayco
|
||||
|
||||
---
|
||||
<p align="center">
|
||||
<a href="https://elk.zone/" target="_blank" rel="noopener noreferrer" >
|
||||
<img src="./public/elk-og.png" alt="Elk screenshots" width="600" height="auto">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## ⚠️ Elk is in Alpha
|
||||
|
||||
|
@ -25,26 +37,60 @@ The Elk team maintains a deployment at:
|
|||
- 🦌 Production: [elk.zone](https://elk.zone)
|
||||
- 🐙 Canary: [main.elk.zone](https://main.elk.zone) (deploys on every commit to `main` branch)
|
||||
|
||||
### Self-Host Docker Deployment
|
||||
### Ecosystem
|
||||
|
||||
In order to host Elk yourself you can use the provided Dockerfile to build a container with elk. Be aware, that Elk only loads properly if the connection is done via SSL/TLS. The Docker container itself does not provide any SSL/TLS handling. You'll have to add this bit yourself.
|
||||
One could put Elk behind popular reverse proxies with SSL Handling like Traefik, NGINX etc.
|
||||
These are known deployments using Elk as an alternative Web client for Mastodon servers or as a base for other projects in the fediverse:
|
||||
|
||||
1. checkout source ```git clone https://github.com/elk-zone/elk.git```
|
||||
1. got into new source dir: ```cd elk```
|
||||
1. create local storage directory for settings: ```mkdir elk-storage```
|
||||
1. adjust permissions of storage dir: ```sudo chown 911:911 ./elk-storage```
|
||||
1. start container: ```docker-compose up --build -d```
|
||||
|
||||
> [!NOTE]
|
||||
> The provided Dockerfile creates a container which will eventually run Elk as non-root user and create a persistent named Docker volume upon first start (if that volume does not yet exist). This volume is always created with root permission. Failing to change the permissions of ```/elk/data``` inside this volume to UID:GID 911 (as specified for Elk in the Dockerfile) will prevent Elk from storing it's config for user accounts. You either have to fix the permission in the created named volume, or mount a directory with the correct permission to ```/elk/data``` into the container.
|
||||
- [elk.h4.io](https://elk.h4.io) - Use Elk for the `h4.io` Server
|
||||
- [elk.universeodon.com](https://elk.universeodon.com) - Use Elk for the Universeodon Server
|
||||
- [elk.vmst.io](https://elk.vmst.io) - Use Elk for the `vmst.io` Server
|
||||
- [elk.hostux.social](https://elk.hostux.social) - Use Elk for the `hostux.social` Server
|
||||
- [elk.freelancers.online](https://elk.freelancers.online) - Use Elk for the `freelancers.online` Server
|
||||
- [elk.cupoftea.social](https://elk.cupoftea.social) - Use Elk for the `cupoftea.social` Server
|
||||
- [elk.aus.social](https://elk.aus.social) - Use Elk for the `aus.social` Server
|
||||
|
||||
> **Note**: Community deployments are **NOT** maintained by the Elk team. It may not be synced with Elk's source code. Please do your own research about the host servers before using them.
|
||||
|
||||
## 💖 Sponsors
|
||||
|
||||
We are grateful for the generous sponsorship and help of:
|
||||
|
||||
<a href="https://nuxtlabs.com/" target="_blank" rel="noopener noreferrer" >
|
||||
<img src="./images/nuxtlabs.svg" alt="NuxtLabs" height="85">
|
||||
</a>
|
||||
<br><br>
|
||||
<a href="https://stackblitz.com/" target="_blank" rel="noopener noreferrer" >
|
||||
<img src="./images/stackblitz.svg" alt="StackBlitz" height="85">
|
||||
</a>
|
||||
<br><br>
|
||||
|
||||
And all the companies and individuals sponsoring Elk Team and the members. If you're enjoying the app, consider sponsoring us:
|
||||
|
||||
- [Elk Team's GitHub Sponsors](https://github.com/sponsors/elk-zone)
|
||||
|
||||
Or you can sponsor our core team members individually:
|
||||
|
||||
- [Anthony Fu](https://github.com/sponsors/antfu)
|
||||
- [Daniel Roe](https://github.com/sponsors/danielroe)
|
||||
- [三咲智子 Kevin Deng](https://github.com/sponsors/sxzz)
|
||||
- [Patak](https://github.com/sponsors/patak-dev)
|
||||
|
||||
We would also appreciate sponsoring other contributors to the Elk project. If someone helps you solve an issue or implement a feature you wanted, supporting them would help make this project and OS more sustainable.
|
||||
|
||||
## 📍 Roadmap
|
||||
|
||||
[Open board on Volta](https://volta.net/elk-zone/elk)
|
||||
|
||||
## 🧑💻 Contributing
|
||||
|
||||
We're really excited that you're interested in contributing to Elk! Before submitting your contribution, please read through the following guide.
|
||||
|
||||
### Online
|
||||
|
||||
You can use [StackBlitz Codeflow](https://stackblitz.com/codeflow) to fix bugs or implement features. You'll also see a Codeflow button on PRs to review them without a local setup. Once the elk repo has been cloned in Codeflow, the dev server will start automatically and print the URL to open the App. You should receive a prompt in the bottom-right suggesting to open it in the Editor or in another Tab. To learn more, check out the [Codeflow docs](https://developer.stackblitz.com/codeflow/what-is-codeflow).
|
||||
|
||||
[](https://pr.new/elk-zone/elk)
|
||||
|
||||
### Local Setup
|
||||
|
||||
Clone the repository and run on the root folder:
|
||||
|
@ -71,10 +117,6 @@ Elk uses [Vitest](https://vitest.dev). You can run the test suite with:
|
|||
nr test
|
||||
```
|
||||
|
||||
## 📲 PWA
|
||||
|
||||
You can consult the [PWA documentation](https://docs.elk.zone/pwa) to learn more about the PWA capabilities on Elk, how to install Elk PWA in your desktop or mobile device and some hints about PWA stuff on Elk.
|
||||
|
||||
## 🦄 Stack
|
||||
|
||||
- [Vite](https://vitejs.dev/) - Next Generation Frontend Tooling
|
||||
|
@ -86,14 +128,14 @@ You can consult the [PWA documentation](https://docs.elk.zone/pwa) to learn more
|
|||
- [UnoCSS](https://uno.antfu.me/) - The instant on-demand atomic CSS engine
|
||||
- [Iconify](https://github.com/iconify/icon-sets#iconify-icon-sets-in-json-format) - Iconify icon sets in JSON format
|
||||
- [Masto.js](https://neet.github.io/masto.js) - Mastodon API client in TypeScript
|
||||
- [shiki](https://shiki.style/) - A beautiful yet powerful syntax highlighter
|
||||
- [vite-plugin-pwa](https://github.com/vite-pwa/vite-plugin-pwa) - Prompt for update, Web Push Notifications and Web Share Target API
|
||||
- [shiki](https://shiki.matsu.io/) - A beautiful Syntax Highlighter
|
||||
- [vite-plugin-pwa](https://github.com/vite-pwa/vite-plugin-pwa) - Prompt for update and push notifications
|
||||
|
||||
## 👨💻 Contributors
|
||||
|
||||
<a href="https://github.com/elk-zone/elk/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=elk-zone/elk" />
|
||||
</a>
|
||||
<img src="https://contrib.rocks/image?repo=elk-zone/elk" />
|
||||
</a>
|
||||
|
||||
## 📄 License
|
||||
|
||||
|
|
|
@ -4,12 +4,10 @@ provideGlobalCommands()
|
|||
|
||||
const route = useRoute()
|
||||
|
||||
if (import.meta.server && !route.path.startsWith('/settings')) {
|
||||
const url = useRequestURL()
|
||||
|
||||
if (process.server && !route.path.startsWith('/settings')) {
|
||||
useHead({
|
||||
meta: [
|
||||
{ property: 'og:url', content: `${url.origin}${route.path}` },
|
||||
{ property: 'og:url', content: `https://elk.zone${route.path}` },
|
||||
],
|
||||
})
|
||||
}
|
|
@ -1,114 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { mastodon } from 'masto'
|
||||
import { toggleFollowAccount, useRelationship } from '~/composables/masto/relationship'
|
||||
|
||||
const { account, context, command, ...props } = defineProps<{
|
||||
account: mastodon.v1.Account
|
||||
relationship?: mastodon.v1.Relationship
|
||||
context?: 'followedBy' | 'following'
|
||||
command?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const isSelf = useSelfAccount(() => account)
|
||||
const enable = computed(() => !isSelf.value && currentUser.value)
|
||||
const relationship = computed(() => props.relationship || useRelationship(account).value)
|
||||
const isLoading = computed(() => relationship.value === undefined)
|
||||
|
||||
const { client } = useMasto()
|
||||
|
||||
async function unblock() {
|
||||
relationship.value!.blocking = false
|
||||
try {
|
||||
const newRel = await client.value.v1.accounts.$select(account.id).unblock()
|
||||
Object.assign(relationship!, newRel)
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
// TODO error handling
|
||||
relationship.value!.blocking = true
|
||||
}
|
||||
}
|
||||
|
||||
async function unmute() {
|
||||
relationship.value!.muting = false
|
||||
try {
|
||||
const newRel = await client.value.v1.accounts.$select(account.id).unmute()
|
||||
Object.assign(relationship!, newRel)
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
// TODO error handling
|
||||
relationship.value!.muting = true
|
||||
}
|
||||
}
|
||||
|
||||
useCommand({
|
||||
scope: 'Actions',
|
||||
order: -2,
|
||||
visible: () => command && enable,
|
||||
name: () => `${relationship.value?.following ? t('account.unfollow') : t('account.follow')} ${getShortHandle(account)}`,
|
||||
icon: 'i-ri:star-line',
|
||||
onActivate: () => toggleFollowAccount(relationship.value!, account),
|
||||
})
|
||||
|
||||
const buttonStyle = computed(() => {
|
||||
if (relationship.value?.blocking)
|
||||
return 'text-inverted bg-red border-red'
|
||||
|
||||
if (relationship.value?.muting)
|
||||
return 'text-base bg-card border-base'
|
||||
|
||||
// If following, use a label style with a strong border for Mutuals
|
||||
if (relationship.value ? relationship.value.following : context === 'following')
|
||||
return `text-base ${relationship.value?.followedBy ? 'border-strong' : 'border-base'}`
|
||||
|
||||
// If loading, use a plain style
|
||||
if (isLoading.value)
|
||||
return 'text-base border-base'
|
||||
|
||||
// If not following, use a button style
|
||||
return 'text-inverted bg-primary border-primary'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
v-if="enable"
|
||||
gap-1 items-center group
|
||||
border-1
|
||||
rounded-full flex="~ gap2 center" font-500 min-w-30 h-fit px3 py1
|
||||
:class="buttonStyle"
|
||||
:hover="!relationship?.blocking && !relationship?.muting && relationship?.following ? 'border-red text-red' : 'bg-base border-primary text-primary'"
|
||||
@click="relationship?.blocking ? unblock() : relationship?.muting ? unmute() : toggleFollowAccount(relationship!, account)"
|
||||
>
|
||||
<template v-if="isLoading">
|
||||
<span i-svg-spinners-180-ring-with-bg />
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="relationship?.blocking">
|
||||
<span elk-group-hover="hidden">{{ $t('account.blocking') }}</span>
|
||||
<span hidden elk-group-hover="inline">{{ $t('account.unblock') }}</span>
|
||||
</template>
|
||||
<template v-if="relationship?.muting">
|
||||
<span elk-group-hover="hidden">{{ $t('account.muting') }}</span>
|
||||
<span hidden elk-group-hover="inline">{{ $t('account.unmute') }}</span>
|
||||
</template>
|
||||
<template v-else-if="relationship ? relationship.following : context === 'following'">
|
||||
<span elk-group-hover="hidden">{{ relationship?.followedBy ? $t('account.mutuals') : $t('account.following') }}</span>
|
||||
<span hidden elk-group-hover="inline">{{ $t('account.unfollow') }}</span>
|
||||
</template>
|
||||
<template v-else-if="relationship?.requested">
|
||||
<span elk-group-hover="hidden">{{ $t('account.follow_requested') }}</span>
|
||||
<span hidden elk-group-hover="inline">{{ $t('account.withdraw_follow_request') }}</span>
|
||||
</template>
|
||||
<template v-else-if="relationship ? relationship.followedBy : context === 'followedBy'">
|
||||
<span elk-group-hover="hidden">{{ $t('account.follows_you') }}</span>
|
||||
<span hidden elk-group-hover="inline">{{ account.locked ? $t('account.request_follow') : $t('account.follow_back') }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ account.locked ? $t('account.request_follow') : $t('account.follow') }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</button>
|
||||
</template>
|
|
@ -1,68 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { mastodon } from 'masto'
|
||||
|
||||
const { account, ...props } = defineProps<{
|
||||
account: mastodon.v1.Account
|
||||
relationship?: mastodon.v1.Relationship
|
||||
}>()
|
||||
const relationship = computed(() => props.relationship || useRelationship(account).value)
|
||||
const { client } = useMasto()
|
||||
|
||||
async function authorizeFollowRequest() {
|
||||
relationship.value!.requestedBy = false
|
||||
relationship.value!.followedBy = true
|
||||
try {
|
||||
const newRel = await client.value.v1.followRequests.$select(account.id).authorize()
|
||||
Object.assign(relationship!, newRel)
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
relationship.value!.requestedBy = true
|
||||
relationship.value!.followedBy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectFollowRequest() {
|
||||
relationship.value!.requestedBy = false
|
||||
try {
|
||||
const newRel = await client.value.v1.followRequests.$select(account.id).reject()
|
||||
Object.assign(relationship!, newRel)
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
relationship.value!.requestedBy = true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex gap-4>
|
||||
<template v-if="relationship?.requestedBy">
|
||||
<CommonTooltip :content="$t('account.authorize')">
|
||||
<button
|
||||
type="button"
|
||||
rounded-full text-sm p2 border-1
|
||||
hover:text-green transition-colors
|
||||
@click="authorizeFollowRequest"
|
||||
>
|
||||
<span block text-current i-ri:check-fill />
|
||||
</button>
|
||||
</CommonTooltip>
|
||||
<CommonTooltip :content="$t('account.reject')">
|
||||
<button
|
||||
type="button"
|
||||
rounded-full text-sm p2 border-1
|
||||
hover:text-red transition-colors
|
||||
@click="rejectFollowRequest"
|
||||
>
|
||||
<span block text-current i-ri:close-fill />
|
||||
</button>
|
||||
</CommonTooltip>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span text-secondary>
|
||||
{{ relationship?.followedBy ? $t('account.authorized') : $t('account.rejected') }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
|
@ -1,276 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { mastodon } from 'masto'
|
||||
|
||||
const { account } = defineProps<{
|
||||
account: mastodon.v1.Account
|
||||
command?: boolean
|
||||
}>()
|
||||
|
||||
const { client } = useMasto()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const createdAt = useFormattedDateTime(() => account.createdAt, {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
const relationship = useRelationship(account)
|
||||
|
||||
const namedFields = ref<mastodon.v1.AccountField[]>([])
|
||||
const iconFields = ref<mastodon.v1.AccountField[]>([])
|
||||
const isEditingPersonalNote = ref<boolean>(false)
|
||||
const hasHeader = computed(() => !account.header.endsWith('/original/missing.png'))
|
||||
const isCopied = ref<boolean>(false)
|
||||
|
||||
function getFieldIconTitle(fieldName: string) {
|
||||
return fieldName === 'Joined' ? t('account.joined') : fieldName
|
||||
}
|
||||
|
||||
function getNotificationIconTitle() {
|
||||
return relationship.value?.notifying ? t('account.notifications_on_post_disable', { username: `@${account.username}` }) : t('account.notifications_on_post_enable', { username: `@${account.username}` })
|
||||
}
|
||||
|
||||
function previewHeader() {
|
||||
openMediaPreview([{
|
||||
id: `${account.acct}:header`,
|
||||
type: 'image',
|
||||
previewUrl: account.header,
|
||||
description: t('account.profile_description', [account.username]),
|
||||
}])
|
||||
}
|
||||
|
||||
function previewAvatar() {
|
||||
openMediaPreview([{
|
||||
id: `${account.acct}:avatar`,
|
||||
type: 'image',
|
||||
previewUrl: account.avatar,
|
||||
description: t('account.avatar_description', [account.username]),
|
||||
}])
|
||||
}
|
||||
|
||||
async function toggleNotifications() {
|
||||
relationship.value!.notifying = !relationship.value?.notifying
|
||||
try {
|
||||
const newRel = await client.value.v1.accounts.$select(account.id).follow({ notify: relationship.value?.notifying })
|
||||
Object.assign(relationship!, newRel)
|
||||
}
|
||||
catch {
|
||||
// TODO error handling
|
||||
relationship.value!.notifying = !relationship.value?.notifying
|
||||
}
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
const named: mastodon.v1.AccountField[] = []
|
||||
const icons: mastodon.v1.AccountField[] = []
|
||||
|
||||
account.fields?.forEach((field) => {
|
||||
const icon = getAccountFieldIcon(field.name)
|
||||
if (icon)
|
||||
icons.push(field)
|
||||
else
|
||||
named.push(field)
|
||||
})
|
||||
icons.push({
|
||||
name: 'Joined',
|
||||
value: createdAt.value,
|
||||
})
|
||||
|
||||
namedFields.value = named
|
||||
iconFields.value = icons
|
||||
})
|
||||
|
||||
const personalNoteDraft = ref(relationship.value?.note ?? '')
|
||||
watch(relationship, (relationship, oldValue) => {
|
||||
if (!oldValue && relationship)
|
||||
personalNoteDraft.value = relationship.note ?? ''
|
||||
})
|
||||
|
||||
async function editNote(event: Event) {
|
||||
if (!event.target || !('value' in event.target) || !relationship.value)
|
||||
return
|
||||
|
||||
const newNote = event.target?.value as string
|
||||
|
||||
if (relationship.value.note?.trim() === newNote.trim())
|
||||
return
|
||||
|
||||
const newNoteApiResult = await client.value.v1.accounts.$select(account.id).note.create({ comment: newNote })
|
||||
relationship.value.note = newNoteApiResult.note
|
||||
personalNoteDraft.value = relationship.value.note ?? ''
|
||||
}
|
||||
|
||||
const isSelf = useSelfAccount(() => account)
|
||||
const isNotifiedOnPost = computed(() => !!relationship.value?.notifying)
|
||||
|
||||
const personalNoteMaxLength = 2000
|
||||
|
||||
async function copyAccountName() {
|
||||
try {
|
||||
const shortHandle = getShortHandle(account)
|
||||
const serverName = getServerName(account)
|
||||
const accountName = `${shortHandle}@${serverName}`
|
||||
await navigator.clipboard.writeText(accountName)
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to copy account name:', err)
|
||||
}
|
||||
|
||||
isCopied.value = true
|
||||
setTimeout(() => {
|
||||
isCopied.value = false
|
||||
}, 2000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex flex-col>
|
||||
<div v-if="relationship?.requestedBy" p-4 flex justify-between items-center bg-card>
|
||||
<span text-primary font-bold>{{ $t('account.requested', [account.displayName]) }}</span>
|
||||
<AccountFollowRequestButton :account="account" :relationship="relationship" />
|
||||
</div>
|
||||
<component :is="hasHeader ? 'button' : 'div'" border="b base" z-1 @click="hasHeader ? previewHeader() : undefined">
|
||||
<img h-50 height="200" w-full object-cover :src="account.header" :alt="t('account.profile_description', [account.username])">
|
||||
</component>
|
||||
<div p4 mt--18 flex flex-col gap-4>
|
||||
<div relative>
|
||||
<div flex justify-between>
|
||||
<button shrink-0 h-full :class="{ 'rounded-full': !isSelf, 'squircle': isSelf }" p1 bg-base border-bg-base z-2 @click="previewAvatar">
|
||||
<AccountAvatar :square="isSelf" :account="account" hover:opacity-90 transition-opacity w-28 h-28 />
|
||||
</button>
|
||||
<div inset-ie-0 flex="~ wrap row-reverse" gap-2 items-center pt18 justify-start>
|
||||
<!-- Edit profile -->
|
||||
<NuxtLink
|
||||
v-if="isSelf"
|
||||
to="/settings/profile/appearance"
|
||||
gap-1 items-center border="1" rounded-full flex="~ gap2 center" font-500 min-w-30 h-fit px3 py1
|
||||
hover="border-primary text-primary bg-active"
|
||||
>
|
||||
{{ $t('settings.profile.appearance.title') }}
|
||||
</NuxtLink>
|
||||
<AccountFollowButton :account="account" :command="command" />
|
||||
<span inset-ie-0 flex gap-2 items-center>
|
||||
<AccountMoreButton
|
||||
:account="account" :command="command"
|
||||
@add-note="isEditingPersonalNote = true"
|
||||
@remove-note="() => { isEditingPersonalNote = false; personalNoteDraft = '' }"
|
||||
/>
|
||||
<CommonTooltip v-if="!isSelf && relationship?.following" :content="getNotificationIconTitle()">
|
||||
<button
|
||||
:aria-pressed="isNotifiedOnPost"
|
||||
:aria-label="t('account.notifications_on_post_enable', { username: `@${account.username}` })"
|
||||
rounded-full text-sm p2 border-1 transition-colors
|
||||
:class="isNotifiedOnPost ? 'text-primary border-primary hover:bg-red/20 hover:text-red hover:border-red' : 'border-base hover:text-primary'"
|
||||
@click="toggleNotifications"
|
||||
>
|
||||
<span v-if="isNotifiedOnPost" i-ri:notification-4-fill block text-current />
|
||||
<span v-else i-ri-notification-4-line block text-current />
|
||||
</button>
|
||||
</CommonTooltip>
|
||||
<CommonTooltip :content="$t('list.modify_account')">
|
||||
<VDropdown v-if="!isSelf && relationship?.following">
|
||||
<button
|
||||
:aria-label="$t('list.modify_account')"
|
||||
rounded-full text-sm p2 border-1 transition-colors
|
||||
border-base hover:text-primary
|
||||
>
|
||||
<span i-ri:play-list-add-fill block text-current />
|
||||
</button>
|
||||
<template #popper>
|
||||
<ListLists :user-id="account.id" />
|
||||
</template>
|
||||
</VDropdown>
|
||||
</CommonTooltip>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div flex="~ col gap1" pt2>
|
||||
<div flex gap2 items-center flex-wrap>
|
||||
<AccountDisplayName :account="account" font-bold sm:text-2xl text-xl />
|
||||
<AccountLockIndicator v-if="account.locked" show-label />
|
||||
<AccountBotIndicator v-if="account.bot" show-label />
|
||||
</div>
|
||||
|
||||
<div flex items-center gap-1>
|
||||
<AccountHandle :account="account" overflow-unset line-clamp-unset />
|
||||
<CommonTooltip placement="bottom" :content="$t('account.copy_account_name')" flex>
|
||||
<button text-secondary-light text-sm :class="isCopied ? 'i-ri:check-fill text-green' : 'i-ri:file-copy-line'" @click="copyAccountName">
|
||||
<span sr-only>{{ $t('account.copy_account_name') }}</span>
|
||||
</button>
|
||||
</CommonTooltip>
|
||||
</div>
|
||||
<div self-start mt-1>
|
||||
<AccountRolesIndicator v-if="account.roles?.length" :account="account" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label
|
||||
v-if="isEditingPersonalNote || (relationship?.note && relationship.note.length > 0)"
|
||||
space-y-2
|
||||
pb-4
|
||||
block
|
||||
border="b base"
|
||||
>
|
||||
<div flex flex-row space-x-2 flex-v-center>
|
||||
<div i-ri-edit-2-line />
|
||||
<p font-medium>
|
||||
{{ $t('account.profile_personal_note') }}
|
||||
</p>
|
||||
<p text-secondary text-sm :class="{ 'text-orange': personalNoteDraft.length > (personalNoteMaxLength - 100) }">
|
||||
{{ personalNoteDraft.length }} / {{ personalNoteMaxLength }}
|
||||
</p>
|
||||
</div>
|
||||
<div position-relative>
|
||||
<div
|
||||
input-base
|
||||
min-h-10ex
|
||||
whitespace-pre-wrap
|
||||
opacity-0
|
||||
:class="{ 'trailing-newline': personalNoteDraft.endsWith('\n') }"
|
||||
>
|
||||
{{ personalNoteDraft }}
|
||||
</div>
|
||||
<textarea
|
||||
v-model="personalNoteDraft"
|
||||
input-base
|
||||
position-absolute
|
||||
style="height: 100%"
|
||||
top-0
|
||||
resize-none
|
||||
:maxlength="personalNoteMaxLength"
|
||||
@change="editNote"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<div v-if="account.note" max-h-100 overflow-y-auto>
|
||||
<ContentRich text-4 text-base :content="account.note" :emojis="account.emojis" />
|
||||
</div>
|
||||
<div v-if="namedFields.length" flex="~ col wrap gap1">
|
||||
<div v-for="field in namedFields" :key="field.name" flex="~ gap-1" items-center>
|
||||
<div mt="0.5" text-secondary uppercase text-xs font-bold>
|
||||
<ContentRich :content="field.name" :emojis="account.emojis" />
|
||||
</div>
|
||||
<span text-secondary text-xs font-bold>|</span>
|
||||
<ContentRich :content="field.value" :emojis="account.emojis" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="iconFields.length" flex="~ wrap gap-2">
|
||||
<div v-for="field in iconFields" :key="field.name" flex="~ gap-1" px1 items-center :class="`${field.verifiedAt ? 'border-1 rounded-full border-dark' : ''}`">
|
||||
<CommonTooltip :content="getFieldIconTitle(field.name)">
|
||||
<div text-secondary :class="getAccountFieldIcon(field.name)" :title="getFieldIconTitle(field.name)" />
|
||||
</CommonTooltip>
|
||||
<ContentRich text-sm :content="field.value" :emojis="account.emojis" />
|
||||
</div>
|
||||
</div>
|
||||
<AccountPostsFollowers :account="account" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.trailing-newline::after {
|
||||
content: '\a';
|
||||
}
|
||||
</style>
|
|
@ -1,70 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { mastodon } from 'masto'
|
||||
import { fetchAccountByHandle } from '~/composables/cache'
|
||||
|
||||
type WatcherType = [acc?: mastodon.v1.Account | null, h?: string, v?: boolean]
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
account?: mastodon.v1.Account | null
|
||||
handle?: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const accountHover = ref()
|
||||
const hovered = useElementHover(accountHover)
|
||||
const account = ref<mastodon.v1.Account | null | undefined>(props.account)
|
||||
|
||||
watch(
|
||||
() => [props.account, props.handle, hovered.value] satisfies WatcherType,
|
||||
([newAccount, newHandle, newVisible], oldProps) => {
|
||||
if (!newVisible || process.test)
|
||||
return
|
||||
|
||||
if (newAccount) {
|
||||
account.value = newAccount
|
||||
return
|
||||
}
|
||||
|
||||
if (newHandle) {
|
||||
const [_oldAccount, oldHandle, _oldVisible] = oldProps ?? [undefined, undefined, false]
|
||||
if (!oldHandle || newHandle !== oldHandle || !account.value) {
|
||||
// new handle can be wrong: using server instead of webDomain
|
||||
fetchAccountByHandle(newHandle).then((acc) => {
|
||||
if (newHandle === props.handle)
|
||||
account.value = acc
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
account.value = undefined
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
|
||||
const userSettings = useUserSettings()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span ref="accountHover">
|
||||
<VMenu
|
||||
v-if="!disabled && account && !getPreferences(userSettings, 'hideAccountHoverCard')"
|
||||
placement="bottom-start"
|
||||
:delay="{ show: 500, hide: 100 }"
|
||||
v-bind="$attrs"
|
||||
:close-on-content-click="false"
|
||||
no-auto-focus
|
||||
>
|
||||
<slot />
|
||||
<template #popper>
|
||||
<AccountHoverCard v-if="account" :account="account" />
|
||||
</template>
|
||||
</VMenu>
|
||||
<slot v-else />
|
||||
</span>
|
||||
</template>
|
|
@ -1,23 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
showLabel?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
flex="~ gap1" items-center
|
||||
:class="{ 'border border-base rounded-md px-1': showLabel }"
|
||||
text-secondary-light
|
||||
>
|
||||
<slot name="prepend" />
|
||||
<CommonTooltip content="Lock" :disabled="showLabel">
|
||||
<div i-ri:lock-line />
|
||||
</CommonTooltip>
|
||||
<div v-if="showLabel">
|
||||
{{ t('account.lock') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -1,78 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { mastodon } from 'masto'
|
||||
|
||||
defineProps<{
|
||||
account: mastodon.v1.Account
|
||||
isHoverCard?: boolean
|
||||
}>()
|
||||
|
||||
const userSettings = useUserSettings()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex gap-5>
|
||||
<NuxtLink
|
||||
:to="getAccountRoute(account)"
|
||||
replace
|
||||
text-secondary
|
||||
exact-active-class="text-primary"
|
||||
>
|
||||
<template #default="{ isExactActive }">
|
||||
<CommonLocalizedNumber
|
||||
keypath="account.posts_count"
|
||||
:count="account.statusesCount"
|
||||
font-bold
|
||||
:class="isExactActive ? 'text-primary' : 'text-base'"
|
||||
/>
|
||||
</template>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="!(isHoverCard && getPreferences(userSettings, 'hideFollowerCount'))"
|
||||
:to="getAccountFollowingRoute(account)"
|
||||
replace
|
||||
text-secondary exact-active-class="text-primary"
|
||||
>
|
||||
<template #default="{ isExactActive }">
|
||||
<template
|
||||
v-if="!getPreferences(userSettings, 'hideFollowerCount')"
|
||||
>
|
||||
<CommonLocalizedNumber
|
||||
v-if="account.followingCount >= 0"
|
||||
keypath="account.following_count"
|
||||
:count="account.followingCount"
|
||||
font-bold
|
||||
:class="isExactActive ? 'text-primary' : 'text-base'"
|
||||
/>
|
||||
<div v-else flex gap-x-1>
|
||||
<span font-bold text-base>Hidden</span>
|
||||
<span>{{ $t('account.following') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else>{{ $t('account.following') }}</span>
|
||||
</template>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="!(isHoverCard && getPreferences(userSettings, 'hideFollowerCount'))"
|
||||
:to="getAccountFollowersRoute(account)"
|
||||
replace text-secondary
|
||||
exact-active-class="text-primary"
|
||||
>
|
||||
<template #default="{ isExactActive }">
|
||||
<template v-if="!getPreferences(userSettings, 'hideFollowerCount')">
|
||||
<CommonLocalizedNumber
|
||||
v-if="account.followersCount >= 0"
|
||||
keypath="account.followers_count"
|
||||
:count="account.followersCount"
|
||||
font-bold
|
||||
:class="isExactActive ? 'text-primary' : 'text-base'"
|
||||
/>
|
||||
<div v-else flex gap-x-1>
|
||||
<span font-bold text-base>Hidden</span>
|
||||
<span>{{ $t('account.followers') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else>{{ $t('account.followers') }}</span>
|
||||
</template>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
|
@ -1,31 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { mastodon } from 'masto'
|
||||
|
||||
defineProps<{
|
||||
account: mastodon.v1.Account
|
||||
limit?: number
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
flex="~ gap1" items-center
|
||||
class="border border-base rounded-md px-1"
|
||||
text-secondary-light
|
||||
>
|
||||
<slot name="prepend" />
|
||||
<div v-for="role in account.roles?.slice(0, limit)" :key="role.id" flex>
|
||||
<div :style="`color: ${role.color}; border-color: ${role.color}`">
|
||||
{{ role.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="limit && account.roles?.length > limit"
|
||||
flex="~ gap1" items-center
|
||||
class="border border-base rounded-md px-1"
|
||||
text-secondary-light
|
||||
>
|
||||
+{{ account.roles?.length - limit }}
|
||||
</div>
|
||||
</template>
|
|
@ -1,46 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { mastodon } from 'masto'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const { tagName } = defineProps<{
|
||||
tagName?: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const tag = ref<mastodon.v1.Tag>()
|
||||
const tagHover = ref()
|
||||
const hovered = useElementHover(tagHover)
|
||||
|
||||
watch(hovered, (newHovered) => {
|
||||
if (newHovered && tagName) {
|
||||
fetchTag(tagName).then((t) => {
|
||||
tag.value = t
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const userSettings = useUserSettings()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span ref="tagHover">
|
||||
<VMenu
|
||||
v-if="!disabled && !getPreferences(userSettings, 'hideTagHoverCard')"
|
||||
placement="bottom-start"
|
||||
:delay="{ show: 500, hide: 100 }"
|
||||
v-bind="$attrs"
|
||||
:close-on-content-click="false"
|
||||
no-auto-focus
|
||||
>
|
||||
<slot />
|
||||
<template #popper>
|
||||
<TagCardSkeleton v-if="!tag" />
|
||||
<TagCard v-else :tag="tag" />
|
||||
</template>
|
||||
</VMenu>
|
||||
<slot v-else />
|
||||
</span>
|
||||
</template>
|
|
@ -1,16 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const { blurhash = '', shouldLoadImage = true } = defineProps<{
|
||||
blurhash?: string
|
||||
src: string
|
||||
srcset?: string
|
||||
shouldLoadImage?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UnLazyImage v-bind="$attrs" :blurhash="blurhash" :src="src" :src-set="srcset" :lazy-load="shouldLoadImage" auto-sizes />
|
||||
</template>
|
|
@ -1,86 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { CommonRouteTabMoreOption, CommonRouteTabOption } from '#shared/types'
|
||||
|
||||
const { options, command, preventScrollTop = false } = defineProps<{
|
||||
options: CommonRouteTabOption[]
|
||||
moreOptions?: CommonRouteTabMoreOption
|
||||
command?: boolean
|
||||
replace?: boolean
|
||||
preventScrollTop?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
useCommands(() => command
|
||||
? options.map(tab => ({
|
||||
scope: 'Tabs',
|
||||
name: tab.display,
|
||||
icon: tab.icon ?? 'i-ri:file-list-2-line',
|
||||
onActivate: () => router.replace(tab.to),
|
||||
}))
|
||||
: [])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex w-full items-center lg:text-lg of-x-auto scrollbar-hide border="b base">
|
||||
<template
|
||||
v-for="(option, index) in options.filter(item => !item.hide)"
|
||||
:key="option?.name || index"
|
||||
>
|
||||
<NuxtLink
|
||||
v-if="!option.disabled"
|
||||
:to="option.to"
|
||||
:replace="replace"
|
||||
relative flex flex-auto cursor-pointer sm:px6 px2 rounded transition-all
|
||||
tabindex="0"
|
||||
hover:bg-active transition-100
|
||||
exact-active-class="children:(text-secondary !border-primary !op100 !text-base)"
|
||||
@click="!preventScrollTop && $scrollToTop()"
|
||||
>
|
||||
<span ws-nowrap mxa sm:px2 sm:py3 xl:pb4 xl:pt5 py2 text-center border-b-3 text-secondary-light hover:text-secondary border-transparent>{{ option.display || ' ' }}</span>
|
||||
</NuxtLink>
|
||||
<div v-else flex flex-auto sm:px6 px2 xl:pb4 xl:pt5>
|
||||
<span ws-nowrap mxa sm:px2 sm:py3 py2 text-center text-secondary-light op50>{{ option.display }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="isHydrated && moreOptions?.options?.length">
|
||||
<CommonDropdown placement="bottom" flex cursor-pointer mx-1.25rem>
|
||||
<CommonTooltip placement="top" :content="moreOptions.tooltip || t('action.more')">
|
||||
<button
|
||||
cursor-pointer
|
||||
flex
|
||||
gap-1
|
||||
w-12
|
||||
rounded
|
||||
hover:bg-active
|
||||
btn-action-icon
|
||||
op75
|
||||
px4
|
||||
group
|
||||
:aria-label="t('action.more')"
|
||||
:class="moreOptions.match ? 'text-primary' : 'text-secondary'"
|
||||
>
|
||||
<span v-if="moreOptions.icon" :class="moreOptions.icon" text-sm me--1 block />
|
||||
<span i-ri:arrow-down-s-line text-sm me--1 block />
|
||||
</button>
|
||||
</CommonTooltip>
|
||||
<template #popper>
|
||||
<NuxtLink
|
||||
v-for="(option, index) in moreOptions.options.filter(item => !item.hide)"
|
||||
:key="option?.name || index"
|
||||
:to="option.to"
|
||||
>
|
||||
<CommonDropdownItem>
|
||||
<span flex="~ row" gap-x-4 items-center :class="option.match ? 'text-primary' : ''">
|
||||
<span v-if="option.icon" :class="[option.icon, option.match ? 'text-primary' : 'text.secondary']" text-md me--1 block />
|
||||
<span v-else block> </span>
|
||||
<span>{{ option.display }}</span>
|
||||
</span>
|
||||
</CommonDropdownItem>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</commondropdown>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
|
@ -1,23 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { mastodon } from 'masto'
|
||||
|
||||
const {
|
||||
history,
|
||||
maxDay = 2,
|
||||
} = defineProps<{
|
||||
history: mastodon.v1.TagHistory[]
|
||||
maxDay?: number
|
||||
}>()
|
||||
|
||||
const ongoingHot = computed(() => history.slice(0, maxDay))
|
||||
|
||||
const people = computed(() =>
|
||||
ongoingHot.value.reduce((total: number, item) => total + (Number(item.accounts) || 0), 0),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p>
|
||||
{{ $t('command.n_people_in_the_past_n_days', [people, maxDay]) }}
|
||||
</p>
|
||||
</template>
|
|
@ -1,30 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
const { alt, dataEmojiId } = defineProps<{
|
||||
as: string
|
||||
alt?: string
|
||||
dataEmojiId?: string
|
||||
}>()
|
||||
|
||||
const title = ref<string | undefined>()
|
||||
|
||||
if (alt) {
|
||||
if (alt.startsWith(':')) {
|
||||
title.value = alt.replace(/:/g, '')
|
||||
}
|
||||
else {
|
||||
import('node-emoji').then(({ find }) => {
|
||||
title.value = find(alt)?.key.replace(/_/g, ' ')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// if it has a data-emoji-id, use that as the title instead
|
||||
if (dataEmojiId)
|
||||
title.value = dataEmojiId
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="as" v-bind="$attrs" :alt="alt" :data-emoji-id="dataEmojiId" :title="title">
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
|
@ -1,23 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { SearchResult } from '~/composables/masto/search'
|
||||
|
||||
defineProps<{
|
||||
result: SearchResult
|
||||
active: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CommonScrollIntoView
|
||||
as="div"
|
||||
:active="active"
|
||||
py2 block px2
|
||||
:aria-selected="active"
|
||||
:class="{ 'bg-active': active }"
|
||||
>
|
||||
<AccountInfo
|
||||
v-if="result.type === 'account'"
|
||||
:account="result.data"
|
||||
/>
|
||||
</CommonScrollIntoView>
|
||||
</template>
|
|
@ -1,166 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
const emit = defineEmits(['close'])
|
||||
const { t } = useI18n()
|
||||
|
||||
/* TODOs:
|
||||
* - I18n
|
||||
*/
|
||||
|
||||
interface ShortcutDef {
|
||||
keys: string[]
|
||||
isSequence: boolean
|
||||
}
|
||||
|
||||
interface ShortcutItem {
|
||||
description: string
|
||||
shortcut: ShortcutDef
|
||||
}
|
||||
|
||||
interface ShortcutItemGroup {
|
||||
name: string
|
||||
items: ShortcutItem[]
|
||||
}
|
||||
|
||||
const isMac = useIsMac()
|
||||
const modifierKeyName = computed(() => isMac.value ? '⌘' : 'Ctrl')
|
||||
|
||||
const shortcutItemGroups = computed<ShortcutItemGroup[]>(() => [
|
||||
{
|
||||
name: t('magic_keys.groups.navigation.title'),
|
||||
items: [
|
||||
{
|
||||
description: t('magic_keys.groups.navigation.shortcut_help'),
|
||||
shortcut: { keys: ['?'], isSequence: false },
|
||||
},
|
||||
{
|
||||
description: t('magic_keys.groups.navigation.next_status'),
|
||||
shortcut: { keys: ['j'], isSequence: false },
|
||||
},
|
||||
{
|
||||
description: t('magic_keys.groups.navigation.previous_status'),
|
||||
shortcut: { keys: ['k'], isSequence: false },
|
||||
},
|
||||
{
|
||||
description: t('magic_keys.groups.navigation.go_to_search'),
|
||||
shortcut: { keys: ['/'], isSequence: false },
|
||||
},
|
||||
{
|
||||
description: t('magic_keys.groups.navigation.go_to_home'),
|
||||
shortcut: { keys: ['g', 'h'], isSequence: true },
|
||||
},
|
||||
{
|
||||
description: t('magic_keys.groups.navigation.go_to_notifications'),
|
||||
shortcut: { keys: ['g', 'n'], isSequence: true },
|
||||
},
|
||||
{
|
||||
description: t('magic_keys.groups.navigation.go_to_conversations'),
|
||||
shortcut: { keys: ['g', 'c'], isSequence: true },
|
||||
},
|
||||
{
|
||||
description: t('magic_keys.groups.navigation.go_to_favourites'),
|
||||
shortcut: { keys: ['g', 'f'], isSequence: true },
|
||||
},
|
||||
{
|
||||
description: t('magic_keys.groups.navigation.go_to_bookmarks'),
|
||||
shortcut: { keys: ['g', 'b'], isSequence: true },
|
||||
},
|
||||
{
|
||||
description: t('magic_keys.groups.navigation.go_to_explore'),
|
||||
shortcut: { keys: ['g', 'e'], isSequence: true },
|
||||
},
|
||||
{
|
||||
description: t('magic_keys.groups.navigation.go_to_local'),
|
||||
shortcut: { keys: ['g', 'l'], isSequence: true },
|
||||
},
|
||||
{
|
||||
description: t('magic_keys.groups.navigation.go_to_federated'),
|
||||
shortcut: { keys: ['g', 't'], isSequence: true },
|
||||
},
|
||||
{
|
||||
description: t('magic_keys.groups.navigation.go_to_lists'),
|
||||
shortcut: { keys: ['g', 'i'], isSequence: true },
|
||||
},
|
||||
{
|
||||
description: t('magic_keys.groups.navigation.go_to_settings'),
|
||||
shortcut: { keys: ['g', 's'], isSequence: true },
|
||||
},
|
||||
{
|
||||
description: t('magic_keys.groups.navigation.go_to_profile'),
|
||||
shortcut: { keys: ['g', 'p'], isSequence: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t('magic_keys.groups.actions.title'),
|
||||
items: [
|
||||
{
|
||||
description: t('magic_keys.groups.actions.search'),
|
||||
shortcut: { keys: [modifierKeyName.value, 'k'], isSequence: false },
|
||||
},
|
||||
{
|
||||
description: t('magic_keys.groups.actions.command_mode'),
|
||||
shortcut: { keys: [modifierKeyName.value, '/'], isSequence: false },
|
||||
},
|
||||
{
|
||||
description: t('magic_keys.groups.actions.compose'),
|
||||
shortcut: { keys: ['c'], isSequence: false },
|
||||
},
|
||||
{
|
||||
description: t('magic_keys.groups.actions.show_new_items'),
|
||||
shortcut: { keys: ['.'], isSequence: false },
|
||||
},
|
||||
{
|
||||
description: t('magic_keys.groups.actions.favourite'),
|
||||
shortcut: { keys: ['f'], isSequence: false },
|
||||
},
|
||||
{
|
||||
description: t('magic_keys.groups.actions.boost'),
|
||||
shortcut: { keys: ['b'], isSequence: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t('magic_keys.groups.media.title'),
|
||||
items: [],
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div px-3 sm:px-5 py-2 sm:py-4 max-w-220 relative max-h-screen>
|
||||
<button btn-action-icon absolute top-1 sm:top-2 right-1 sm:right-2 m1 :aria-label="$t('modals.aria_label_close')" @click="emit('close')">
|
||||
<div i-ri:close-fill />
|
||||
</button>
|
||||
<h2 text-xl font-700 mb3>
|
||||
{{ $t('magic_keys.dialog_header') }}
|
||||
</h2>
|
||||
<div mb2 grid grid-cols-1 md:grid-cols-3 gap-y- md:gap-x-6 lg:gap-x-8>
|
||||
<div
|
||||
v-for="group in shortcutItemGroups"
|
||||
:key="group.name"
|
||||
>
|
||||
<h3 font-700 my-2 text-lg>
|
||||
{{ group.name }}
|
||||
</h3>
|
||||
<div
|
||||
v-for="item in group.items"
|
||||
:key="item.description"
|
||||
flex my-1 lg:my-2 justify-between place-items-center max-w-full text-base
|
||||
>
|
||||
<div mr-2 break-words overflow-hidden leading-4 h-full inline-block align-middle>
|
||||
{{ item.description }}
|
||||
</div>
|
||||
<div>
|
||||
<template
|
||||
v-for="(key, idx) in item.shortcut.keys"
|
||||
:key="idx"
|
||||
>
|
||||
<span v-if="idx !== 0" mx1 text-sm op80>{{ item.shortcut.isSequence ? $t('magic_keys.sequence_then') : '+' }}</span>
|
||||
<code class="px2 md:px1.5 lg:px2 lg:px2 py0 lg:py-0.5" rounded bg-code border="px $c-border-code" shadow-sm my1 font-mono font-600>{{ key }}</code>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -1,70 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
/** Show the back button on small screens */
|
||||
backOnSmallScreen?: boolean
|
||||
/** Show the back button on both small and big screens */
|
||||
back?: boolean
|
||||
/** Do not applying overflow hidden to let use floatable components in title */
|
||||
noOverflowHidden?: boolean
|
||||
}>()
|
||||
|
||||
const container = ref()
|
||||
const route = useRoute()
|
||||
const userSettings = useUserSettings()
|
||||
const { height: windowHeight } = useWindowSize()
|
||||
const { height: containerHeight } = useElementBounding(container)
|
||||
const wideLayout = computed(() => route.meta.wideLayout ?? false)
|
||||
const sticky = computed(() => route.path?.startsWith('/settings/'))
|
||||
const containerClass = computed(() => {
|
||||
// we keep original behavior when not in settings page and when the window height is smaller than the container height
|
||||
if (!isHydrated.value || !sticky.value || (windowHeight.value < containerHeight.value))
|
||||
return null
|
||||
|
||||
return 'lg:sticky lg:top-0'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" :class="containerClass">
|
||||
<div
|
||||
sticky top-0 z-20
|
||||
pt="[env(safe-area-inset-top,0)]"
|
||||
bg="[rgba(var(--rgb-bg-base),0.7)]"
|
||||
class="native:lg:w-[calc(100vw-5rem)] native:xl:w-[calc(135%+(100vw-1200px)/2)]"
|
||||
:class="{
|
||||
'backdrop-blur': !getPreferences(userSettings, 'optimizeForLowPerformanceDevice'),
|
||||
}"
|
||||
>
|
||||
<div flex justify-between gap-2 min-h-53px px5 py1 :class="{ 'xl:hidden': $route.name !== 'tag' }" class="native:xl:flex" border="b base">
|
||||
<div flex gap-2 items-center :overflow-hidden="!noOverflowHidden ? '' : false" w-full>
|
||||
<button
|
||||
v-if="backOnSmallScreen || back"
|
||||
btn-text flex items-center ms="-3" p-3 xl:hidden
|
||||
:aria-label="$t('nav.back')"
|
||||
@click="$router.go(-1)"
|
||||
>
|
||||
<div text-lg i-ri:arrow-left-line class="rtl-flip" />
|
||||
</button>
|
||||
<div :truncate="!noOverflowHidden ? '' : false" flex w-full data-tauri-drag-region class="native-mac:justify-start native-mac:text-center">
|
||||
<slot name="title" />
|
||||
</div>
|
||||
<div sm:hidden h-7 w-1px />
|
||||
</div>
|
||||
<div flex items-center flex-shrink-0 gap-x-2>
|
||||
<slot name="actions" />
|
||||
<PwaBadge xl:hidden />
|
||||
<NavUser v-if="isHydrated" />
|
||||
<NavUserSkeleton v-else />
|
||||
</div>
|
||||
</div>
|
||||
<slot name="header">
|
||||
<div hidden />
|
||||
</slot>
|
||||
</div>
|
||||
<PwaInstallPrompt xl:hidden />
|
||||
<div :class="isHydrated && wideLayout ? 'xl:w-full sm:max-w-600px' : 'sm:max-w-600px md:shrink-0'" m-auto>
|
||||
<div hidden :class="{ 'xl:block': $route.name !== 'tag' && !$slots.header }" h-6 />
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -1,45 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
const model = defineModel<number>()
|
||||
const isValid = defineModel<boolean>('isValid')
|
||||
|
||||
const days = ref<number | ''>(0)
|
||||
const hours = ref<number | ''>(1)
|
||||
const minutes = ref<number | ''>(0)
|
||||
|
||||
watchEffect(() => {
|
||||
if (days.value === '' || hours.value === '' || minutes.value === '') {
|
||||
isValid.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const duration
|
||||
= days.value * 24 * 60 * 60
|
||||
+ hours.value * 60 * 60
|
||||
+ minutes.value * 60
|
||||
|
||||
if (duration <= 0) {
|
||||
isValid.value = false
|
||||
return
|
||||
}
|
||||
|
||||
isValid.value = true
|
||||
model.value = duration
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex flex-grow-0 gap-2>
|
||||
<label flex items-center gap-2>
|
||||
<input v-model="days" type="number" min="0" max="1999" input-base :class="!isValid ? 'input-error' : null">
|
||||
{{ $t('confirm.mute_account.days', days === '' ? 0 : days) }}
|
||||
</label>
|
||||
<label flex items-center gap-2>
|
||||
<input v-model="hours" type="number" min="0" max="24" input-base :class="!isValid ? 'input-error' : null">
|
||||
{{ $t('confirm.mute_account.hours', hours === '' ? 0 : hours) }}
|
||||
</label>
|
||||
<label flex items-center gap-2>
|
||||
<input v-model="minutes" type="number" min="0" max="59" step="5" input-base :class="!isValid ? 'input-error' : null">
|
||||
{{ $t('confirm.mute_account.minute', minutes === '' ? 0 : minutes) }}
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
|
@ -1,56 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { ConfirmDialogChoice, ConfirmDialogOptions } from '#shared/types'
|
||||
|
||||
const { extraOptionType } = defineProps<ConfirmDialogOptions>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(evt: 'choice', choice: ConfirmDialogChoice): void
|
||||
}>()
|
||||
|
||||
const hasDuration = ref(false)
|
||||
const isValidDuration = ref(true)
|
||||
const duration = ref(60 * 60) // default to 1 hour
|
||||
const shouldMuteNotifications = ref(true)
|
||||
const isMute = computed(() => extraOptionType === 'mute')
|
||||
|
||||
function handleChoice(choice: ConfirmDialogChoice['choice']) {
|
||||
const dialogChoice = {
|
||||
choice,
|
||||
...isMute.value && {
|
||||
extraOptions: {
|
||||
mute: {
|
||||
duration: hasDuration.value ? duration.value : 0,
|
||||
notifications: shouldMuteNotifications.value,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
emit('choice', dialogChoice)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex="~ col" gap-6>
|
||||
<div font-bold text-lg>
|
||||
{{ title }}
|
||||
</div>
|
||||
<div v-if="description">
|
||||
{{ description }}
|
||||
</div>
|
||||
<div v-if="isMute" flex-col flex gap-4>
|
||||
<CommonCheckbox v-model="hasDuration" :label="$t('confirm.mute_account.specify_duration')" prepend-checkbox checked-icon-color="text-primary" />
|
||||
<ModalDurationPicker v-if="hasDuration" v-model="duration" v-model:is-valid="isValidDuration" />
|
||||
<CommonCheckbox v-model="shouldMuteNotifications" :label="$t('confirm.mute_account.notifications')" prepend-checkbox checked-icon-color="text-primary" />
|
||||
</div>
|
||||
|
||||
<div flex justify-end gap-2>
|
||||
<button btn-text @click="handleChoice('cancel')">
|
||||
{{ cancel || $t('confirm.common.cancel') }}
|
||||
</button>
|
||||
<button btn-solid :disabled="!isValidDuration" @click="handleChoice('confirm')">
|
||||
{{ confirm || $t('confirm.common.confirm') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -1,31 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { ErrorDialogData } from '#shared/types'
|
||||
|
||||
defineProps<ErrorDialogData>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex="~ col" gap-6>
|
||||
<div font-bold text-lg text-center>
|
||||
{{ title }}
|
||||
</div>
|
||||
<div
|
||||
flex="~ col"
|
||||
gap-1 text-sm
|
||||
pt-1 ps-2 pe-1 pb-2
|
||||
text-red-600 dark:text-red-400
|
||||
border="~ base rounded red-600 dark:red-400"
|
||||
>
|
||||
<ol ps-2 sm:ps-1>
|
||||
<li v-for="(message, i) in messages" :key="i" flex="~ col sm:row" gap-y-1 sm:gap-x-2>
|
||||
{{ message }}
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div flex justify-end gap-2>
|
||||
<button btn-text @click="closeErrorDialog()">
|
||||
{{ close }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -1,286 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { Vector2 } from '@vueuse/gesture'
|
||||
import type { mastodon } from 'masto'
|
||||
import { useGesture } from '@vueuse/gesture'
|
||||
import { useReducedMotion } from '@vueuse/motion'
|
||||
|
||||
const { media = [] } = defineProps<{
|
||||
media?: mastodon.v1.MediaAttachment[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<number>({ required: true })
|
||||
|
||||
const slideGap = 20
|
||||
const doubleTapThreshold = 250
|
||||
|
||||
const view = ref()
|
||||
const slider = ref()
|
||||
const slide = ref()
|
||||
const image = ref()
|
||||
|
||||
const reduceMotion = import.meta.server ? ref(false) : useReducedMotion()
|
||||
const isInitialScrollDone = useTimeout(350)
|
||||
const canAnimate = computed(() => isInitialScrollDone.value && !reduceMotion.value)
|
||||
|
||||
const scale = ref(1)
|
||||
const x = ref(0)
|
||||
const y = ref(0)
|
||||
|
||||
const isDragging = ref(false)
|
||||
const isPinching = ref(false)
|
||||
|
||||
const maxZoomOut = ref(1)
|
||||
const isZoomedIn = computed(() => scale.value > 1)
|
||||
|
||||
const enableAutoplay = usePreferences('enableAutoplay')
|
||||
|
||||
function goToFocusedSlide() {
|
||||
scale.value = 1
|
||||
x.value = slide.value[modelValue.value].offsetLeft * scale.value
|
||||
y.value = 0
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const slideGapAsScale = slideGap / view.value.clientWidth
|
||||
maxZoomOut.value = 1 - slideGapAsScale
|
||||
|
||||
goToFocusedSlide()
|
||||
})
|
||||
watch(modelValue, goToFocusedSlide)
|
||||
|
||||
let lastOrigin = [0, 0]
|
||||
let initialScale = 0
|
||||
useGesture({
|
||||
onPinch({ first, initial: [initialDistance], movement: [deltaDistance], da: [distance], origin, touches }) {
|
||||
isPinching.value = true
|
||||
|
||||
if (first) {
|
||||
initialScale = scale.value
|
||||
}
|
||||
else {
|
||||
if (touches === 0)
|
||||
handleMouseWheelZoom(initialScale, deltaDistance, origin)
|
||||
else
|
||||
handlePinchZoom(initialScale, initialDistance, distance, origin)
|
||||
}
|
||||
|
||||
lastOrigin = origin
|
||||
},
|
||||
onPinchEnd() {
|
||||
isPinching.value = false
|
||||
isDragging.value = false
|
||||
|
||||
if (!isZoomedIn.value)
|
||||
goToFocusedSlide()
|
||||
},
|
||||
onDrag({ movement, delta, pinching, tap, last, swipe, event, xy }) {
|
||||
event.preventDefault()
|
||||
|
||||
if (pinching)
|
||||
return
|
||||
|
||||
if (last)
|
||||
handleLastDrag(tap, swipe, movement, xy)
|
||||
else
|
||||
handleDrag(delta, movement)
|
||||
},
|
||||
}, {
|
||||
domTarget: view,
|
||||
eventOptions: {
|
||||
passive: false,
|
||||
},
|
||||
})
|
||||
|
||||
const shiftRestrictions = computed(() => {
|
||||
const focusedImage = image.value[modelValue.value]
|
||||
const focusedSlide = slide.value[modelValue.value]
|
||||
|
||||
const scaledImageWidth = focusedImage.offsetWidth * scale.value
|
||||
const scaledHorizontalOverflow = scaledImageWidth / 2 - view.value.clientWidth / 2 + slideGap
|
||||
const horizontalOverflow = Math.max(0, scaledHorizontalOverflow / scale.value)
|
||||
|
||||
const scaledImageHeight = focusedImage.offsetHeight * scale.value
|
||||
const scaledVerticalOverflow = scaledImageHeight / 2 - view.value.clientHeight / 2 + slideGap
|
||||
const verticalOverflow = Math.max(0, scaledVerticalOverflow / scale.value)
|
||||
|
||||
return {
|
||||
left: focusedSlide.offsetLeft - horizontalOverflow,
|
||||
right: focusedSlide.offsetLeft + horizontalOverflow,
|
||||
top: focusedSlide.offsetTop - verticalOverflow,
|
||||
bottom: focusedSlide.offsetTop + verticalOverflow,
|
||||
}
|
||||
})
|
||||
|
||||
function handlePinchZoom(initialScale: number, initialDistance: number, distance: number, [originX, originY]: Vector2) {
|
||||
scale.value = initialScale * (distance / initialDistance)
|
||||
scale.value = Math.max(maxZoomOut.value, scale.value)
|
||||
|
||||
const deltaCenterX = originX - lastOrigin[0]
|
||||
const deltaCenterY = originY - lastOrigin[1]
|
||||
|
||||
handleZoomDrag([deltaCenterX, deltaCenterY])
|
||||
}
|
||||
|
||||
function handleMouseWheelZoom(initialScale: number, deltaDistance: number, [originX, originY]: Vector2) {
|
||||
scale.value = initialScale + (deltaDistance / 1000)
|
||||
scale.value = Math.max(maxZoomOut.value, scale.value)
|
||||
|
||||
const deltaCenterX = lastOrigin[0] - originX
|
||||
const deltaCenterY = lastOrigin[1] - originY
|
||||
|
||||
handleZoomDrag([deltaCenterX, deltaCenterY])
|
||||
}
|
||||
|
||||
function handleLastDrag(tap: boolean, swipe: Vector2, movement: Vector2, position: Vector2) {
|
||||
isDragging.value = false
|
||||
|
||||
if (tap)
|
||||
handleTap(position)
|
||||
else if (swipe[0] || swipe[1])
|
||||
handleSwipe(swipe, movement)
|
||||
else if (!isZoomedIn.value)
|
||||
slideToClosestSlide()
|
||||
}
|
||||
|
||||
let lastTapAt = 0
|
||||
function handleTap([positionX, positionY]: Vector2) {
|
||||
const now = Date.now()
|
||||
const isDoubleTap = now - lastTapAt < doubleTapThreshold
|
||||
lastTapAt = now
|
||||
|
||||
if (!isDoubleTap)
|
||||
return
|
||||
|
||||
if (isZoomedIn.value) {
|
||||
goToFocusedSlide()
|
||||
}
|
||||
else {
|
||||
const focusedSlideBounding = slide.value[modelValue.value].getBoundingClientRect()
|
||||
const slideCenterX = focusedSlideBounding.left + focusedSlideBounding.width / 2
|
||||
const slideCenterY = focusedSlideBounding.top + focusedSlideBounding.height / 2
|
||||
|
||||
scale.value = 3
|
||||
x.value += positionX - slideCenterX
|
||||
y.value += positionY - slideCenterY
|
||||
restrictShiftToInsideSlide()
|
||||
}
|
||||
}
|
||||
|
||||
function handleSwipe([horiz, vert]: Vector2, [movementX, movementY]: Vector2) {
|
||||
if (isZoomedIn.value || isPinching.value)
|
||||
return
|
||||
|
||||
const isHorizontalDrag = Math.abs(movementX) >= Math.abs(movementY)
|
||||
|
||||
if (isHorizontalDrag) {
|
||||
if (horiz === 1) // left
|
||||
modelValue.value = Math.max(0, modelValue.value - 1)
|
||||
if (horiz === -1) // right
|
||||
modelValue.value = Math.min(media.length - 1, modelValue.value + 1)
|
||||
}
|
||||
else if (vert === 1 || vert === -1) {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
goToFocusedSlide()
|
||||
}
|
||||
|
||||
function slideToClosestSlide() {
|
||||
const startOfFocusedSlide = slide.value[modelValue.value].offsetLeft * scale.value
|
||||
const slideWidth = slide.value[modelValue.value].offsetWidth * scale.value
|
||||
|
||||
if (x.value > startOfFocusedSlide + slideWidth / 2)
|
||||
modelValue.value = Math.min(media.length - 1, modelValue.value + 1)
|
||||
else if (x.value < startOfFocusedSlide - slideWidth / 2)
|
||||
modelValue.value = Math.max(0, modelValue.value - 1)
|
||||
|
||||
goToFocusedSlide()
|
||||
}
|
||||
|
||||
function handleDrag(delta: Vector2, movement: Vector2) {
|
||||
isDragging.value = true
|
||||
|
||||
if (isZoomedIn.value)
|
||||
handleZoomDrag(delta)
|
||||
else
|
||||
handleSlideDrag(movement)
|
||||
}
|
||||
|
||||
function handleZoomDrag([deltaX, deltaY]: Vector2) {
|
||||
x.value -= deltaX / scale.value
|
||||
y.value -= deltaY / scale.value
|
||||
|
||||
restrictShiftToInsideSlide()
|
||||
}
|
||||
|
||||
function handleSlideDrag([movementX, movementY]: Vector2) {
|
||||
goToFocusedSlide()
|
||||
|
||||
if (Math.abs(movementY) > Math.abs(movementX)) // vertical movement is more than horizontal
|
||||
y.value -= movementY / scale.value
|
||||
else
|
||||
x.value -= movementX / scale.value
|
||||
|
||||
if (media.length === 1)
|
||||
x.value = 0
|
||||
}
|
||||
|
||||
function restrictShiftToInsideSlide() {
|
||||
x.value = Math.min(shiftRestrictions.value.right, Math.max(shiftRestrictions.value.left, x.value))
|
||||
y.value = Math.min(shiftRestrictions.value.bottom, Math.max(shiftRestrictions.value.top, y.value))
|
||||
}
|
||||
|
||||
const sliderStyle = computed(() => {
|
||||
const style = {
|
||||
transform: `scale(${scale.value}) translate(${-x.value}px, ${-y.value}px)`,
|
||||
transition: 'none',
|
||||
gap: `${slideGap}px`,
|
||||
}
|
||||
|
||||
if (canAnimate.value && !isDragging.value && !isPinching.value)
|
||||
style.transition = 'all 0.3s ease'
|
||||
|
||||
return style
|
||||
})
|
||||
|
||||
const imageStyle = computed(() => ({
|
||||
cursor: isDragging.value ? 'grabbing' : 'grab',
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="view" flex flex-row h-full w-full overflow-hidden>
|
||||
<div ref="slider" :style="sliderStyle" w-full h-full flex items-center>
|
||||
<div
|
||||
v-for="item in media"
|
||||
:key="item.id"
|
||||
ref="slide"
|
||||
flex-shrink-0
|
||||
w-full
|
||||
h-full
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
>
|
||||
<component
|
||||
:is="item.type === 'gifv' ? 'video' : 'img'"
|
||||
ref="image"
|
||||
:autoplay="enableAutoplay"
|
||||
controls
|
||||
loop
|
||||
select-none
|
||||
max-w-full
|
||||
max-h-full
|
||||
:style="imageStyle"
|
||||
:draggable="false"
|
||||
:src="item.url || item.previewUrl"
|
||||
:alt="item.description || ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -1,64 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import type { NavButtonName } from '../../composables/settings'
|
||||
|
||||
import {
|
||||
NavButtonBookmark,
|
||||
NavButtonCompose,
|
||||
NavButtonExplore,
|
||||
NavButtonFavorite,
|
||||
NavButtonFederated,
|
||||
NavButtonHashtag,
|
||||
NavButtonHome,
|
||||
NavButtonList,
|
||||
NavButtonLocal,
|
||||
NavButtonMention,
|
||||
NavButtonMoreMenu,
|
||||
NavButtonNotification,
|
||||
NavButtonSearch,
|
||||
} from '#components'
|
||||
|
||||
import { STORAGE_KEY_BOTTOM_NAV_BUTTONS } from '~/constants'
|
||||
|
||||
interface NavButton {
|
||||
name: string
|
||||
component: Component
|
||||
}
|
||||
|
||||
const navButtons: NavButton[] = [
|
||||
{ name: 'home', component: NavButtonHome },
|
||||
{ name: 'search', component: NavButtonSearch },
|
||||
{ name: 'notification', component: NavButtonNotification },
|
||||
{ name: 'mention', component: NavButtonMention },
|
||||
{ name: 'favorite', component: NavButtonFavorite },
|
||||
{ name: 'bookmark', component: NavButtonBookmark },
|
||||
{ name: 'compose', component: NavButtonCompose },
|
||||
{ name: 'explore', component: NavButtonExplore },
|
||||
{ name: 'local', component: NavButtonLocal },
|
||||
{ name: 'federated', component: NavButtonFederated },
|
||||
{ name: 'list', component: NavButtonList },
|
||||
{ name: 'hashtag', component: NavButtonHashtag },
|
||||
{ name: 'moreMenu', component: NavButtonMoreMenu },
|
||||
]
|
||||
|
||||
const defaultSelectedNavButtonNames: NavButtonName[] = currentUser.value
|
||||
? ['home', 'search', 'notification', 'mention', 'moreMenu']
|
||||
: ['explore', 'local', 'federated', 'moreMenu']
|
||||
const selectedNavButtonNames = useLocalStorage<NavButtonName[]>(STORAGE_KEY_BOTTOM_NAV_BUTTONS, defaultSelectedNavButtonNames)
|
||||
|
||||
const selectedNavButtons = computed(() => selectedNavButtonNames.value.map(name => navButtons.find(navButton => navButton.name === name)))
|
||||
|
||||
// only one icon can be lit up at the same time
|
||||
const moreMenuVisible = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- This weird styles above are used for scroll locking, don't change it unless you know exactly what you're doing. -->
|
||||
<nav
|
||||
h-14 border="t base" flex flex-row text-xl
|
||||
of-y-scroll scrollbar-hide overscroll-none
|
||||
class="after-content-empty after:(h-[calc(100%+0.5px)] w-0.1px pointer-events-none)"
|
||||
>
|
||||
<Component :is="navButton!.component" v-for="navButton in selectedNavButtons" :key="navButton!.name" :active-class="moreMenuVisible ? '' : 'text-primary'" />
|
||||
</nav>
|
||||
</template>
|
|
@ -1,194 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { invoke } from '@vueuse/core'
|
||||
|
||||
const modelValue = defineModel<boolean>({ required: true })
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const userSettings = useUserSettings()
|
||||
|
||||
const drawerEl = ref<HTMLDivElement>()
|
||||
|
||||
function toggleVisible() {
|
||||
modelValue.value = !modelValue.value
|
||||
}
|
||||
|
||||
const buttonEl = ref<HTMLDivElement>()
|
||||
/**
|
||||
* Close the drop-down menu if the mouse click is not on the drop-down menu button when the drop-down menu is opened
|
||||
* @param mouse
|
||||
*/
|
||||
function clickEvent(mouse: MouseEvent) {
|
||||
if (mouse.target && !buttonEl.value?.children[0].contains(mouse.target as any)) {
|
||||
if (modelValue.value) {
|
||||
document.removeEventListener('click', clickEvent)
|
||||
modelValue.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDark() {
|
||||
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
watch(modelValue, (val) => {
|
||||
if (val && typeof document !== 'undefined')
|
||||
document.addEventListener('click', clickEvent)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', clickEvent)
|
||||
})
|
||||
|
||||
// Pull down to close
|
||||
const { dragging, dragDistance } = invoke(() => {
|
||||
const triggerDistance = 120
|
||||
|
||||
let scrollTop = 0
|
||||
let beforeTouchPointY = 0
|
||||
|
||||
const dragDistance = ref(0)
|
||||
const dragging = ref(false)
|
||||
|
||||
useEventListener(drawerEl, 'scroll', (e: Event) => {
|
||||
scrollTop = (e.target as HTMLDivElement).scrollTop
|
||||
|
||||
// Prevent the page from scrolling when the drawer is being dragged.
|
||||
if (dragDistance.value > 0)
|
||||
(e.target as HTMLDivElement).scrollTop = 0
|
||||
}, { passive: true })
|
||||
|
||||
useEventListener(drawerEl, 'touchstart', (e: TouchEvent) => {
|
||||
if (!modelValue.value)
|
||||
return
|
||||
|
||||
beforeTouchPointY = e.touches[0].pageY
|
||||
dragDistance.value = 0
|
||||
}, { passive: true })
|
||||
|
||||
useEventListener(drawerEl, 'touchmove', (e: TouchEvent) => {
|
||||
if (!modelValue.value)
|
||||
return
|
||||
|
||||
// Do not move the entire drawer when its contents are not scrolled to the top.
|
||||
if (scrollTop > 0 && dragDistance.value <= 0) {
|
||||
dragging.value = false
|
||||
beforeTouchPointY = e.touches[0].pageY
|
||||
return
|
||||
}
|
||||
|
||||
const { pageY } = e.touches[0]
|
||||
|
||||
// Calculate the drag distance.
|
||||
dragDistance.value += pageY - beforeTouchPointY
|
||||
if (dragDistance.value < 0)
|
||||
dragDistance.value = 0
|
||||
beforeTouchPointY = pageY
|
||||
|
||||
// Marked as dragging.
|
||||
if (dragDistance.value > 1)
|
||||
dragging.value = true
|
||||
|
||||
// Prevent the page from scrolling when the drawer is being dragged.
|
||||
if (dragDistance.value > 0) {
|
||||
if (e?.cancelable && e?.preventDefault)
|
||||
e.preventDefault()
|
||||
e?.stopPropagation()
|
||||
}
|
||||
}, { passive: true })
|
||||
|
||||
useEventListener(drawerEl, 'touchend', () => {
|
||||
if (!modelValue.value)
|
||||
return
|
||||
|
||||
if (dragDistance.value >= triggerDistance)
|
||||
modelValue.value = false
|
||||
|
||||
dragging.value = false
|
||||
// code
|
||||
}, { passive: true })
|
||||
|
||||
return {
|
||||
dragDistance,
|
||||
dragging,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="buttonEl" flex items-center static>
|
||||
<slot :toggle-visible="toggleVisible" :show="modelValue" />
|
||||
|
||||
<!-- Drawer -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-250 ease-out"
|
||||
enter-from-class="opacity-0 children:(translate-y-full)"
|
||||
enter-to-class="opacity-100 children:(translate-y-0)"
|
||||
leave-active-class="transition duration-250 ease-in"
|
||||
leave-from-class="opacity-100 children:(translate-y-0)"
|
||||
leave-to-class="opacity-0 children:(translate-y-full)"
|
||||
>
|
||||
<div
|
||||
v-show="modelValue"
|
||||
absolute inset-x-0 top-auto bottom-full z-20 h-100vh
|
||||
flex items-end of-y-scroll of-x-hidden scrollbar-hide overscroll-none
|
||||
bg="black/50"
|
||||
>
|
||||
<!-- The style `scrollbar-hide overscroll-none overflow-y-scroll mb="-1px"` and `h="[calc(100%+0.5px)]"` is used to implement scroll locking, -->
|
||||
<!-- corresponding to issue: #106, so please don't remove it. -->
|
||||
<div absolute inset-0 opacity-0 h="[calc(100vh+0.5px)]" />
|
||||
<div
|
||||
ref="drawerEl"
|
||||
:style="{
|
||||
transform: dragging ? `translateY(${dragDistance}px)` : '',
|
||||
}"
|
||||
:class="{
|
||||
'duration-0': dragging,
|
||||
'duration-250': !dragging,
|
||||
'backdrop-blur-md': !getPreferences(userSettings, 'optimizeForLowPerformanceDevice'),
|
||||
}"
|
||||
transition="transform ease-in"
|
||||
flex-1 min-w-48 py-6 mb="-1px"
|
||||
of-y-auto scrollbar-hide overscroll-none max-h="[calc(100vh-200px)]"
|
||||
rounded-t-lg bg="white/85 dark:neutral-900/85" backdrop-filter
|
||||
border-t-1 border-base
|
||||
>
|
||||
<!-- Nav -->
|
||||
<NavSide />
|
||||
|
||||
<!-- Divider line -->
|
||||
<div border="neutral-300 dark:neutral-700 t-1" m="x-3 y-2" />
|
||||
|
||||
<!-- Function menu -->
|
||||
<div flex="~ col gap2">
|
||||
<!-- Toggle Theme -->
|
||||
<button
|
||||
flex flex-row items-center
|
||||
block px-5 py-2 focus-blue w-full
|
||||
text-sm text-base capitalize text-left whitespace-nowrap
|
||||
transition-colors duration-200 transform
|
||||
hover="bg-gray-100 dark:(bg-gray-700 text-white)"
|
||||
@click="toggleDark()"
|
||||
>
|
||||
<span class="i-ri:sun-line dark:i-ri:moon-line flex-shrink-0 text-xl me-4 !align-middle" />
|
||||
{{ colorMode.value === 'light' ? $t('menu.toggle_theme.dark') : $t('menu.toggle_theme.light') }}
|
||||
</button>
|
||||
|
||||
<!-- Zen Mode -->
|
||||
<button
|
||||
flex flex-row items-center
|
||||
block px-5 py-2 focus-blue w-full
|
||||
text-sm text-base capitalize text-left whitespace-nowrap
|
||||
transition-colors duration-200 transform
|
||||
hover="bg-gray-100 dark:(bg-gray-700 text-white)"
|
||||
:aria-label="$t('nav.zen_mode')"
|
||||
@click="togglePreferences('zenMode')"
|
||||
>
|
||||
<span :class="getPreferences(userSettings, 'zenMode') ? 'i-ri:layout-right-2-line' : 'i-ri:layout-right-line'" class="flex-shrink-0 text-xl me-4 !align-middle" />
|
||||
{{ $t('nav.zen_mode') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
|
@ -1,23 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span shrink-0 aspect="1/1" sm:h-8 xl:h-10 class="rtl-flip">
|
||||
<!-- <svg
|
||||
xmlns="http://www.w3.org/2000/svg" w-full
|
||||
aspect="1/1" sm:h-8 xl:h-10 sm:w-8 xl:w-10 viewBox="0 0 250 250" fill="none"
|
||||
> -->
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"><defs><mask id="ipTEgg0"><g fill="none" stroke="#fff" stroke-width="4"><circle cx="24" cy="24" r="10" fill="#555555" stroke-linecap="round" stroke-linejoin="round" /><path d="M44 24c0 2.633-.508 5.146-1.433 7.448c-.936 2.331-4.129.071-7.346 3.521c-3.216 3.45-.71 6.267-3.204 7.36A19.9 19.9 0 0 1 24 44C12.954 44 4 35.046 4 24S12.954 4 24 4s20 8.954 20 20Z" /><path stroke-linecap="round" d="M20 25s.21 1.21 1 2s2 1 2 1" /></g></mask></defs><path fill="#ff8d00" d="M0 0h48v48H0z" mask="url(#ipTEgg0)" /></svg>
|
||||
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
svg path.wood {
|
||||
fill: var(--c-primary);
|
||||
}
|
||||
svg path.body {
|
||||
fill: var(--c-text-secondary);
|
||||
}
|
||||
</style>
|
|
@ -1,82 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE, STORAGE_KEY_LAST_ACCESSED_NOTIFICATION_ROUTE } from '~/constants'
|
||||
|
||||
defineProps<{
|
||||
command?: boolean
|
||||
}>()
|
||||
const { notifications } = useNotifications()
|
||||
const useStarFavoriteIcon = usePreferences('useStarFavoriteIcon')
|
||||
const lastAccessedNotificationRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_NOTIFICATION_ROUTE, '')
|
||||
const lastAccessedExploreRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE, '')
|
||||
|
||||
const notificationsLink = computed(() => {
|
||||
const hydrated = isHydrated.value
|
||||
const user = currentUser.value
|
||||
const lastRoute = lastAccessedNotificationRoute.value
|
||||
if (!hydrated || !user || !lastRoute) {
|
||||
return '/notifications'
|
||||
}
|
||||
|
||||
return `/notifications/${lastRoute}`
|
||||
})
|
||||
const exploreLink = computed(() => {
|
||||
const hydrated = isHydrated.value
|
||||
const server = currentServer.value
|
||||
let lastRoute = lastAccessedExploreRoute.value
|
||||
if (!hydrated) {
|
||||
return '/explore'
|
||||
}
|
||||
|
||||
if (lastRoute.length) {
|
||||
lastRoute = `/${lastRoute}`
|
||||
}
|
||||
|
||||
return server ? `/${server}/explore${lastRoute}` : `/explore${lastRoute}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav sm:px3 flex="~ col gap2" shrink text-size-base leading-normal md:text-lg h-full mt-1 overflow-y-auto>
|
||||
<NavSideItem :text="$t('nav.search')" to="/search" icon="i-ri:search-line" xl:hidden :command="command" />
|
||||
|
||||
<div class="spacer" shrink xl:hidden />
|
||||
<NavSideItem :text="$t('nav.home')" to="/home" icon="i-ri:home-5-line" user-only :command="command" />
|
||||
<NavSideItem :text="$t('nav.notifications')" :to="notificationsLink" icon="i-ri:notification-4-line" user-only :command="command">
|
||||
<template #icon>
|
||||
<div flex relative>
|
||||
<div class="i-ri:notification-4-line" text-xl />
|
||||
<div v-if="notifications" class="top-[-0.3rem] right-[-0.3rem]" absolute font-bold rounded-full h-4 w-4 text-xs bg-primary text-inverted flex items-center justify-center>
|
||||
{{ notifications < 10 ? notifications : '•' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</NavSideItem>
|
||||
<NavSideItem :text="$t('nav.conversations')" to="/conversations" icon="i-ri:at-line" user-only :command="command" />
|
||||
<NavSideItem :text="$t('nav.favourites')" to="/favourites" :icon="useStarFavoriteIcon ? 'i-ri:star-line' : 'i-ri:heart-3-line'" user-only :command="command" />
|
||||
<NavSideItem :text="$t('nav.bookmarks')" to="/bookmarks" icon="i-ri:bookmark-line" user-only :command="command" />
|
||||
|
||||
<div class="spacer" shrink hidden sm:block />
|
||||
<NavSideItem :text="$t('action.compose')" to="/compose" icon="i-ri:quill-pen-line" user-only :command="command" />
|
||||
|
||||
<div class="spacer" shrink hidden sm:block />
|
||||
<NavSideItem :text="$t('nav.explore')" :to="exploreLink" icon="i-ri:compass-3-line" :command="command" />
|
||||
<NavSideItem :text="$t('nav.local')" :to="isHydrated ? `/${currentServer}/public/local` : '/public/local'" icon="i-ri:group-2-line " :command="command" />
|
||||
<NavSideItem :text="$t('nav.federated')" :to="isHydrated ? `/${currentServer}/public` : '/public'" icon="i-ri:earth-line" :command="command" />
|
||||
<NavSideItem :text="$t('nav.lists')" :to="isHydrated ? `/${currentServer}/lists` : '/lists'" icon="i-ri:list-check" user-only :command="command" />
|
||||
<NavSideItem :text="$t('nav.hashtags')" to="/hashtags" icon="i-ri:hashtag" user-only :command="command" />
|
||||
|
||||
<div class="spacer" shrink hidden sm:block />
|
||||
<NavSideItem :text="$t('nav.settings')" to="/settings" icon="i-ri:settings-3-line" :command="command" />
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.spacer {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
@media screen and ( max-height: 920px ) and ( min-width: 640px ) {
|
||||
.spacer {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,108 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
const { text, icon, to, userOnly = false, command } = defineProps<{
|
||||
text?: string
|
||||
icon: string
|
||||
to: string | Record<string, string>
|
||||
userOnly?: boolean
|
||||
command?: boolean
|
||||
}>()
|
||||
|
||||
defineSlots<{
|
||||
icon: (props: object) => void
|
||||
default: (props: object) => void
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
useCommand({
|
||||
scope: 'Navigation',
|
||||
|
||||
name: () => text ?? (typeof to === 'string' ? to as string : to.name),
|
||||
icon: () => icon,
|
||||
visible: () => command,
|
||||
|
||||
onActivate() {
|
||||
router.push(to)
|
||||
},
|
||||
})
|
||||
|
||||
const activeClass = ref('text-primary')
|
||||
onHydrated(async () => {
|
||||
// TODO: force NuxtLink to reevaluate, we now we are in this route though, so we should force it to active
|
||||
// we don't have currentServer defined until later
|
||||
activeClass.value = ''
|
||||
await nextTick()
|
||||
activeClass.value = 'text-primary'
|
||||
})
|
||||
|
||||
// Optimize rendering for the common case of being logged in, only show visual feedback for disabled user-only items
|
||||
// when we know there is no user.
|
||||
const noUserDisable = computed(() => !isHydrated.value || (userOnly && !currentUser.value))
|
||||
const noUserVisual = computed(() => isHydrated.value && userOnly && !currentUser.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink
|
||||
:to="to"
|
||||
:disabled="noUserDisable"
|
||||
:class="noUserVisual ? 'op25 pointer-events-none ' : ''"
|
||||
:active-class="activeClass"
|
||||
group focus:outline-none disabled:pointer-events-none
|
||||
:tabindex="noUserDisable ? -1 : null"
|
||||
@click="$scrollToTop"
|
||||
>
|
||||
<CommonTooltip :disabled="!isMediumOrLargeScreen" :content="text" placement="right">
|
||||
<div
|
||||
class="item"
|
||||
flex items-center gap4
|
||||
xl="ml0 mr5 px5 w-auto"
|
||||
:class="isSmallScreen
|
||||
? `
|
||||
w-full
|
||||
px5 sm:mxa
|
||||
transition-colors duration-200 transform
|
||||
hover-bg-gray-100 hover-dark:(bg-gray-700 text-white)
|
||||
` : `
|
||||
w-fit rounded-3
|
||||
px2 mx3 sm:mxa
|
||||
transition-100
|
||||
elk-group-hover-bg-active
|
||||
group-focus-visible:ring-2
|
||||
group-focus-visible:ring-current
|
||||
`"
|
||||
>
|
||||
<slot name="icon">
|
||||
<div :class="icon" text-xl />
|
||||
</slot>
|
||||
<slot>
|
||||
<span block sm:hidden xl:block select-none>{{ isHydrated ? text : ' ' }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
</CommonTooltip>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.item {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
@media screen and ( max-height: 820px ) and ( min-width: 1280px ) {
|
||||
.item {
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
}
|
||||
@media screen and ( max-height: 780px ) and ( min-width: 640px ) {
|
||||
.item {
|
||||
padding-top: 0.35rem;
|
||||
padding-bottom: 0.35rem;
|
||||
}
|
||||
}
|
||||
@media screen and ( max-height: 780px ) and ( min-width: 1280px ) {
|
||||
.item {
|
||||
padding-top: 0.05rem;
|
||||
padding-bottom: 0.05rem;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,50 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
const { env } = useBuildInfo()
|
||||
const router = useRouter()
|
||||
const back = ref<any>('')
|
||||
|
||||
const nuxtApp = useNuxtApp()
|
||||
|
||||
function onClickLogo() {
|
||||
nuxtApp.hooks.callHook('elk-logo:click')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
back.value = router.options.history.state.back
|
||||
})
|
||||
router.afterEach(() => {
|
||||
back.value = router.options.history.state.back
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex justify-between sticky top-0 bg-base z-1 py-4 native:py-7 data-tauri-drag-region>
|
||||
<NuxtLink
|
||||
flex items-end gap-3
|
||||
py2 px-5
|
||||
text-2xl
|
||||
select-none
|
||||
focus-visible:ring="2 current"
|
||||
to="/home"
|
||||
@click.prevent="onClickLogo"
|
||||
>
|
||||
<NavLogo shrink-0 aspect="1/1" sm:h-8 xl:h-10 class="rtl-flip" />
|
||||
<div v-show="isHydrated" hidden xl:block>
|
||||
<span pr-1>{{ $t('app_name') }}</span>
|
||||
<sup text-sm italic mt-1>{{ env === 'release' ? 'alpha' : env }}</sup>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<div hidden xl:flex items-center me-6 mt-2 gap-1>
|
||||
<CommonTooltip :content="$t('nav.back')" :distance="0">
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="$t('nav.back')"
|
||||
btn-text p-3 :class="{ 'pointer-events-none op0': !back || back === '/', 'xl:flex': $route.name !== 'tag' }"
|
||||
@click="$router.go(-1)"
|
||||
>
|
||||
<div text-xl i-ri:arrow-left-line class="rtl-flip" />
|
||||
</button>
|
||||
</CommonTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -1,47 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
const { busy, oauth, singleInstanceServer } = useSignIn()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDropdown v-if="isHydrated && currentUser" sm:hidden>
|
||||
<div style="-webkit-touch-callout: none;">
|
||||
<AccountAvatar
|
||||
:account="currentUser.account"
|
||||
h-8
|
||||
w-8
|
||||
:draggable="false"
|
||||
square
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #popper="{ hide }">
|
||||
<UserSwitcher @click="hide()" />
|
||||
</template>
|
||||
</VDropdown>
|
||||
<template v-else>
|
||||
<button
|
||||
v-if="singleInstanceServer"
|
||||
flex="~ row"
|
||||
gap-x-1 items-center justify-center btn-solid text-sm px-2 py-1 xl:hidden
|
||||
:disabled="busy"
|
||||
@click="oauth()"
|
||||
>
|
||||
<span v-if="busy" aria-hidden="true" block animate animate-spin preserve-3d class="rtl-flip">
|
||||
<span block i-ri:loader-2-fill aria-hidden="true" />
|
||||
</span>
|
||||
<span v-else aria-hidden="true" block i-ri:login-circle-line class="rtl-flip" />
|
||||
<i18n-t keypath="action.sign_in_to">
|
||||
<strong>{{ currentServer }}</strong>
|
||||
</i18n-t>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
flex="~ row"
|
||||
gap-x-1 items-center justify-center btn-solid text-sm px-2 py-1 xl:hidden
|
||||
@click="openSigninDialog()"
|
||||
>
|
||||
<span aria-hidden="true" block i-ri:login-circle-line class="rtl-flip" />
|
||||
{{ $t('action.sign_in') }}
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
|
@ -1,11 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
activeClass: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink to="/bookmarks" :aria-label="$t('nav.bookmarks')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
|
||||
<div i-ri:bookmark-line />
|
||||
</NuxtLink>
|
||||
</template>
|
|
@ -1,11 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
activeClass: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink to="/compose" :aria-label="$t('nav.favourites')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
|
||||
<div i-ri:quill-pen-line />
|
||||
</NuxtLink>
|
||||
</template>
|
|
@ -1,15 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE } from '~/constants'
|
||||
|
||||
defineProps<{
|
||||
activeClass: string
|
||||
}>()
|
||||
|
||||
const lastAccessedExploreRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE, '')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink :to="`/${currentServer}/explore/${lastAccessedExploreRoute}`" :aria-label="$t('nav.explore')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
|
||||
<div i-ri:compass-3-line />
|
||||
</NuxtLink>
|
||||
</template>
|
|
@ -1,11 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
activeClass: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink to="/favourites" :aria-label="$t('nav.favourites')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
|
||||
<div i-ri:heart-line />
|
||||
</NuxtLink>
|
||||
</template>
|
|
@ -1,11 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
activeClass: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink :to="`/${currentServer}/public`" :aria-label="$t('nav.federated')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
|
||||
<div i-ri:earth-line />
|
||||
</NuxtLink>
|
||||
</template>
|
|
@ -1,11 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
activeClass: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink to="/hashtags" :aria-label="$t('nav.hashtags')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
|
||||
<div i-ri:hashtag />
|
||||
</NuxtLink>
|
||||
</template>
|
|
@ -1,11 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
activeClass: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink to="/home" :aria-label="$t('nav.home')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
|
||||
<div i-ri:home-5-line />
|
||||
</NuxtLink>
|
||||
</template>
|
|
@ -1,17 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
activeClass: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink
|
||||
to="/lists"
|
||||
:aria-label="$t('nav.lists')"
|
||||
:active-class="activeClass"
|
||||
flex flex-row items-center place-content-center h-full flex-1
|
||||
class="coarse-pointer:select-none" @click="$scrollToTop"
|
||||
>
|
||||
<div i-ri:list-check />
|
||||
</NuxtLink>
|
||||
</template>
|
|
@ -1,11 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
activeClass: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink group :to="`/${currentServer}/public/local`" :aria-label="$t('nav.local')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
|
||||
<div i-ri:group-2-line />
|
||||
</NuxtLink>
|
||||
</template>
|
|
@ -1,15 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
activeClass: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink
|
||||
to="/conversations" :aria-label="$t('nav.conversations')"
|
||||
:active-class="activeClass" flex flex-row items-center place-content-center h-full
|
||||
flex-1 class="coarse-pointer:select-none" @click="$scrollToTop"
|
||||
>
|
||||
<div i-ri:at-line />
|
||||
</NuxtLink>
|
||||
</template>
|
|
@ -1,19 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
const model = defineModel<boolean>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavBottomMoreMenu
|
||||
v-slot="{ toggleVisible, show }" v-model="model!" flex flex-row items-center
|
||||
place-content-center h-full flex-1 cursor-pointer
|
||||
>
|
||||
<button
|
||||
flex items-center place-content-center h-full flex-1 class="select-none"
|
||||
:class="show ? '!text-primary' : ''"
|
||||
:aria-label="$t('nav.more_menu')"
|
||||
@click="toggleVisible"
|
||||
>
|
||||
<span :class="show ? 'i-ri:close-fill' : 'i-ri:more-fill'" />
|
||||
</button>
|
||||
</NavBottomMoreMenu>
|
||||
</template>
|
|
@ -1,21 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { STORAGE_KEY_LAST_ACCESSED_NOTIFICATION_ROUTE } from '~/constants'
|
||||
|
||||
defineProps<{
|
||||
activeClass: string
|
||||
}>()
|
||||
|
||||
const { notifications } = useNotifications()
|
||||
const lastAccessedNotificationRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_NOTIFICATION_ROUTE, '')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink :to="`/notifications/${lastAccessedNotificationRoute}`" :aria-label="$t('nav.notifications')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
|
||||
<div flex relative>
|
||||
<div class="i-ri:notification-4-line" text-xl />
|
||||
<div v-if="notifications" class="top-[-0.3rem] right-[-0.3rem]" absolute font-bold rounded-full h-4 w-4 text-xs bg-primary text-inverted flex items-center justify-center>
|
||||
{{ notifications < 10 ? notifications : '•' }}
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
|
@ -1,11 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
activeClass: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink to="/search" :aria-label="$t('nav.search')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
|
||||
<div i-ri:search-line />
|
||||
</NuxtLink>
|
||||
</template>
|
|
@ -1,154 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { mastodon } from 'masto'
|
||||
|
||||
// Add undocumented 'annual_report' type introduced in v4.3
|
||||
// ref. https://github.com/mastodon/documentation/issues/1211#:~:text=api/v1/annual_reports
|
||||
type NotificationType = mastodon.v1.Notification['type'] | 'annual_report'
|
||||
type Notification = Omit<mastodon.v1.Notification, 'type'> & { type: NotificationType }
|
||||
|
||||
const { notification } = defineProps<{
|
||||
notification: Notification
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// list of notification types Elk currently implemented
|
||||
// type 'favourite' and 'reblog' should always rendered by NotificationGroupedLikes
|
||||
const supportedNotificationTypes: NotificationType[] = [
|
||||
'follow',
|
||||
'admin.sign_up',
|
||||
'admin.report',
|
||||
'follow_request',
|
||||
'update',
|
||||
'mention',
|
||||
'poll',
|
||||
'update',
|
||||
'status',
|
||||
'annual_report',
|
||||
]
|
||||
|
||||
// well-known emoji reactions types Elk does not support yet
|
||||
const unsupportedEmojiReactionTypes = ['pleroma:emoji_reaction', 'reaction']
|
||||
|
||||
if (unsupportedEmojiReactionTypes.includes(notification.type) || !supportedNotificationTypes.includes(notification.type)) {
|
||||
console.warn(`[DEV] ${t('notification.missing_type')} '${notification.type}' (notification.id: ${notification.id})`)
|
||||
}
|
||||
|
||||
const timeAgoOptions = useTimeAgoOptions(true)
|
||||
const timeAgo = useTimeAgo(() => notification.createdAt, timeAgoOptions)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article flex flex-col relative>
|
||||
<template v-if="notification.type === 'follow'">
|
||||
<NuxtLink :to="getAccountRoute(notification.account)">
|
||||
<div
|
||||
flex items-center absolute
|
||||
ps-3 pe-4 inset-is-0
|
||||
rounded-ie-be-3
|
||||
py-3 bg-base top-0
|
||||
>
|
||||
<div i-ri-user-3-line text-xl me-3 color-blue />
|
||||
<AccountDisplayName :account="notification.account" text-primary me-1 font-bold line-clamp-1 ws-pre-wrap break-all />
|
||||
<span ws-nowrap>
|
||||
{{ $t('notification.followed_you') }}
|
||||
<time text-secondary :datetime="notification.createdAt">
|
||||
・{{ timeAgo }}
|
||||
</time>
|
||||
</span>
|
||||
</div>
|
||||
<AccountBigCard
|
||||
ms10
|
||||
:account="notification.account"
|
||||
/>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template v-else-if="notification.type === 'admin.sign_up'">
|
||||
<NuxtLink :to="getAccountRoute(notification.account)">
|
||||
<div flex p4 items-center bg-shaded>
|
||||
<div i-ri:user-add-line text-xl me-2 color-purple />
|
||||
<AccountDisplayName
|
||||
:account="notification.account"
|
||||
text-purple me-1 font-bold line-clamp-1 ws-pre-wrap break-all
|
||||
/>
|
||||
<span>{{ $t("notification.signed_up") }}
|
||||
<time text-secondary :datetime="notification.createdAt">
|
||||
・{{ timeAgo }}
|
||||
</time>
|
||||
</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template v-else-if="notification.type === 'admin.report'">
|
||||
<NuxtLink :to="getReportRoute(notification.report?.id!)">
|
||||
<div flex p4 items-center bg-shaded>
|
||||
<div i-ri:flag-line text-xl me-2 color-purple />
|
||||
<i18n-t keypath="notification.reported">
|
||||
<AccountDisplayName
|
||||
:account="notification.account"
|
||||
text-purple me-1 font-bold line-clamp-1 ws-pre-wrap break-all
|
||||
/>
|
||||
<AccountDisplayName
|
||||
:account="notification.report?.targetAccount!"
|
||||
text-purple ms-1 font-bold line-clamp-1 ws-pre-wrap break-all
|
||||
/>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template v-else-if="notification.type === 'follow_request'">
|
||||
<div flex px-3 py-2>
|
||||
<div i-ri-user-shared-line text-xl me-3 color-blue />
|
||||
<AccountDisplayName
|
||||
:account="notification.account"
|
||||
text-primary me-1 font-bold line-clamp-1 ws-pre-wrap break-all
|
||||
/>
|
||||
<span me-1 ws-nowrap>
|
||||
{{ $t('notification.request_to_follow') }}
|
||||
<time text-secondary :datetime="notification.createdAt">
|
||||
・{{ timeAgo }}
|
||||
</time>
|
||||
</span>
|
||||
</div>
|
||||
<AccountCard p="s-2 e-4 b-2" hover-card :account="notification.account">
|
||||
<AccountFollowRequestButton :account="notification.account" />
|
||||
</AccountCard>
|
||||
</template>
|
||||
<template v-else-if="notification.type === 'update'">
|
||||
<StatusCard :status="notification.status!" :in-notification="true" :actions="false">
|
||||
<template #meta>
|
||||
<div flex="~" gap-1 items-center mt1>
|
||||
<div i-ri:edit-2-fill text-xl me-1 text-secondary />
|
||||
<AccountInlineInfo :account="notification.account" me1 />
|
||||
<span ws-nowrap>
|
||||
{{ $t('notification.update_status') }}
|
||||
<time text-secondary :datetime="notification.createdAt">
|
||||
・{{ timeAgo }}
|
||||
</time>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</StatusCard>
|
||||
</template>
|
||||
<template v-else-if="notification.type === 'mention' || notification.type === 'poll' || notification.type === 'status'">
|
||||
<StatusCard :status="notification.status!" />
|
||||
</template>
|
||||
<template v-else-if="notification.type === 'annual_report'">
|
||||
<div flex p4 items-center bg-shaded>
|
||||
<div i-mdi:party-popper text-xl me-4 color-purple />
|
||||
<div class="content-rich">
|
||||
<p>
|
||||
Your 2024 <NuxtLink to="/tags/Wrapstodon">
|
||||
#Wrapstodon
|
||||
</NuxtLink> awaits! Unveil your year's highlights and memorable moments on Mastodon!
|
||||
</p>
|
||||
<p>
|
||||
<NuxtLink :to="`https://${currentServer}/notifications`" target="_blank">
|
||||
View #Wrapstodon on Mastodon
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</article>
|
||||
</template>
|
|
@ -1,102 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { GroupedNotifications } from '#shared/types'
|
||||
|
||||
const { items } = defineProps<{
|
||||
items: GroupedNotifications
|
||||
}>()
|
||||
|
||||
const maxVisibleFollows = 5
|
||||
const follows = computed(() => items.items)
|
||||
const visibleFollows = computed(() => follows.value.slice(0, maxVisibleFollows))
|
||||
const count = computed(() => follows.value.length)
|
||||
const countPlus = computed(() => Math.max(count.value - maxVisibleFollows, 0))
|
||||
const isExpanded = ref(false)
|
||||
const lang = computed(() => {
|
||||
return (count.value > 1 || count.value === 0) ? undefined : items.items[0].status?.language
|
||||
})
|
||||
|
||||
const timeAgoOptions = useTimeAgoOptions(true)
|
||||
const timeAgoCreatedAt = computed(() => follows.value[0].createdAt)
|
||||
const timeAgo = useTimeAgo(() => timeAgoCreatedAt.value, timeAgoOptions)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article flex flex-col relative :lang="lang ?? undefined">
|
||||
<div flex items-center top-0 left-2 pt-2 px-3>
|
||||
<div :class="count > 1 ? 'i-ri-group-line' : 'i-ri-user-3-line'" me-3 color-blue text-xl aria-hidden="true" />
|
||||
<template v-if="count > 1">
|
||||
<AccountHoverWrapper
|
||||
:account="follows[0].account"
|
||||
>
|
||||
<NuxtLink :to="getAccountRoute(follows[0].account)">
|
||||
<AccountDisplayName
|
||||
:account="follows[0].account"
|
||||
text-primary font-bold line-clamp-1 ws-pre-wrap break-all hover:underline
|
||||
/>
|
||||
</NuxtLink>
|
||||
</AccountHoverWrapper>
|
||||
{{ $t('notification.and') }}
|
||||
<CommonLocalizedNumber
|
||||
keypath="notification.others"
|
||||
:count="count - 1"
|
||||
text-primary font-bold line-clamp-1 ws-pre-wrap break-all
|
||||
/>
|
||||
{{ $t('notification.followed_you') }}
|
||||
<time text-secondary :datetime="timeAgoCreatedAt">
|
||||
・{{ timeAgo }}
|
||||
</time>
|
||||
</template>
|
||||
<template v-else-if="count === 1">
|
||||
<NuxtLink :to="getAccountRoute(follows[0].account)">
|
||||
<AccountDisplayName
|
||||
:account="follows[0].account"
|
||||
text-primary me-1 font-bold line-clamp-1 ws-pre-wrap break-all hover:underline
|
||||
/>
|
||||
</NuxtLink>
|
||||
<span me-1 ws-nowrap>
|
||||
{{ $t('notification.followed_you') }}
|
||||
<time text-secondary :datetime="timeAgoCreatedAt">
|
||||
・{{ timeAgo }}
|
||||
</time>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div pb-2 ps8>
|
||||
<div
|
||||
v-if="!isExpanded && count > 1"
|
||||
flex="~ wrap gap-1.75" p4 items-center cursor-pointer
|
||||
@click="isExpanded = !isExpanded"
|
||||
>
|
||||
<AccountHoverWrapper
|
||||
v-for="follow in visibleFollows"
|
||||
:key="follow.id"
|
||||
:account="follow.account"
|
||||
>
|
||||
<NuxtLink :to="getAccountRoute(follow.account)">
|
||||
<AccountAvatar :account="follow.account" w-12 h-12 />
|
||||
</NuxtLink>
|
||||
</AccountHoverWrapper>
|
||||
<div flex="~ 1" items-center>
|
||||
<span v-if="countPlus > 0" ps-2 text="base lg">+{{ countPlus }}</span>
|
||||
<div i-ri:arrow-down-s-line mx-1 text-secondary text-xl aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="count > 1" flex p-4 pb-2 cursor-pointer @click="isExpanded = !isExpanded">
|
||||
<div i-ri:arrow-up-s-line ms-2 text-secondary text-xl aria-hidden="true" />
|
||||
<span ps-2 text-base>Hide</span>
|
||||
</div>
|
||||
<AccountHoverWrapper
|
||||
v-for="follow in follows"
|
||||
:key="follow.id"
|
||||
:account="follow.account"
|
||||
>
|
||||
<NuxtLink :to="getAccountRoute(follow.account)" flex gap-4 px-4 py-2>
|
||||
<AccountAvatar :account="follow.account" w-12 h-12 />
|
||||
<StatusAccountDetails :account="follow.account" />
|
||||
</NuxtLink>
|
||||
</AccountHoverWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
|
@ -1,74 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { GroupedLikeNotifications } from '#shared/types'
|
||||
|
||||
const { group } = defineProps<{
|
||||
group: GroupedLikeNotifications
|
||||
}>()
|
||||
const useStarFavoriteIcon = usePreferences('useStarFavoriteIcon')
|
||||
|
||||
const reblogs = computed(() => group.likes.filter(i => i.reblog))
|
||||
const likes = computed(() => group.likes.filter(i => i.favourite && !i.reblog))
|
||||
|
||||
const timeAgoOptions = useTimeAgoOptions(true)
|
||||
const reblogsTimeAgoCreatedAt = computed(() => reblogs.value[0].reblog?.createdAt)
|
||||
const reblogsTimeAgo = useTimeAgo(() => reblogsTimeAgoCreatedAt.value ?? '', timeAgoOptions)
|
||||
const likesTimeAgoCreatedAt = computed(() => likes.value[0].favourite?.createdAt)
|
||||
const likesTimeAgo = useTimeAgo(() => likesTimeAgoCreatedAt.value ?? '', timeAgoOptions)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article flex flex-col relative>
|
||||
<StatusLink :status="group.status!" pb4 pt5>
|
||||
<div flex flex-col gap-3>
|
||||
<div v-if="reblogs.length" flex="~ gap-1">
|
||||
<div i-ri:repeat-fill text-xl me-2 color-green />
|
||||
<template v-for="i, idx of reblogs" :key="idx">
|
||||
<AccountHoverWrapper :account="i.account">
|
||||
<NuxtLink :to="getAccountRoute(i.account)">
|
||||
<AccountAvatar text-primary font-bold :account="i.account" class="h-1.5em w-1.5em" />
|
||||
</NuxtLink>
|
||||
</AccountHoverWrapper>
|
||||
</template>
|
||||
<div ml1>
|
||||
{{ $t('notification.reblogged_post') }}
|
||||
<time text-secondary :datetime="reblogsTimeAgoCreatedAt">
|
||||
・{{ reblogsTimeAgo }}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="likes.length" flex="~ gap-1 wrap">
|
||||
<div :class="useStarFavoriteIcon ? 'i-ri:star-line color-yellow' : 'i-ri:heart-line color-red'" text-xl me-2 />
|
||||
<template v-for="i, idx of likes" :key="idx">
|
||||
<AccountHoverWrapper :account="i.account" relative me--4 border="2 bg-base" rounded-full hover:z-1 focus-within:z-1>
|
||||
<NuxtLink :to="getAccountRoute(i.account)">
|
||||
<AccountAvatar text-primary font-bold :account="i.account" class="h-1.5em w-1.5em" />
|
||||
</NuxtLink>
|
||||
</AccountHoverWrapper>
|
||||
</template>
|
||||
<div ms-4>
|
||||
{{ $t('notification.favourited_post') }}
|
||||
<time text-secondary :datetime="likesTimeAgoCreatedAt">
|
||||
・{{ likesTimeAgo }}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ps9 mt-1>
|
||||
<StatusBody :status="group.status!" text-secondary />
|
||||
<!-- When no text content is presented, we show media instead -->
|
||||
<template v-if="!group.status!.content">
|
||||
<StatusMedia
|
||||
v-if="group.status!.mediaAttachments?.length"
|
||||
:status="group.status!"
|
||||
:is-preview="false"
|
||||
pointer-events-none
|
||||
/>
|
||||
<StatusPoll
|
||||
v-else-if="group.status!.poll"
|
||||
:status="group.status!"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</StatusLink>
|
||||
</article>
|
||||
</template>
|
|
@ -1,12 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
max: number
|
||||
length: number
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div dir="ltr" pointer-events-none pe-1 pt-2 text-sm tabular-nums text-secondary flex gap="0.5" :class="{ 'text-rose-500': length > max }">
|
||||
{{ length ?? 0 }}<span text-secondary-light>/</span><span text-secondary-light>{{ max }}</span>
|
||||
</div>
|
||||
</template>
|
|
@ -1,54 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { Editor } from '@tiptap/core'
|
||||
|
||||
defineProps<{
|
||||
editor: Editor
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CommonTooltip placement="top" :content="$t('tooltip.open_editor_tools')">
|
||||
<VDropdown v-if="editor" placement="bottom">
|
||||
<button
|
||||
btn-action-icon
|
||||
:aria-label="$t('tooltip.open_editor_tools')"
|
||||
>
|
||||
<div i-ri:font-size-2 />
|
||||
</button>
|
||||
<template #popper>
|
||||
<div flex gap-1>
|
||||
<CommonTooltip placement="top" :content="$t('tooltip.toggle_code_block')">
|
||||
<button
|
||||
btn-action-icon
|
||||
:aria-label="$t('tooltip.toggle_code_block')"
|
||||
:class="editor.isActive('codeBlock') ? 'text-primary' : ''"
|
||||
@click="editor?.chain().focus().toggleCodeBlock().run()"
|
||||
>
|
||||
<div i-ri:code-s-slash-line />
|
||||
</button>
|
||||
</CommonTooltip>
|
||||
<CommonTooltip placement="top" :content="$t('tooltip.toggle_bold')">
|
||||
<button
|
||||
btn-action-icon
|
||||
:aria-label="$t('tooltip.toggle_bold')"
|
||||
:class="editor.isActive('bold') ? 'text-primary' : ''"
|
||||
@click="editor?.chain().focus().toggleBold().run()"
|
||||
>
|
||||
<div i-ri:bold />
|
||||
</button>
|
||||
</CommonTooltip>
|
||||
<CommonTooltip placement="top" :content="$t('tooltip.toggle_italic')">
|
||||
<button
|
||||
btn-action-icon
|
||||
:aria-label="$t('tooltip.toggle_italic')"
|
||||
:class="editor.isActive('italic') ? 'text-primary' : ''"
|
||||
@click="editor?.chain().focus().toggleItalic().run()"
|
||||
>
|
||||
<div i-ri:italic />
|
||||
</button>
|
||||
</CommonTooltip>
|
||||
</div>
|
||||
</template>
|
||||
</VDropdown>
|
||||
</CommonTooltip>
|
||||
</template>
|
|
@ -1,71 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { Picker } from 'emoji-mart'
|
||||
import importEmojiLang from 'virtual:emoji-mart-lang-importer'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', code: string): void
|
||||
(e: 'selectCustom', image: any): void
|
||||
}>()
|
||||
|
||||
const { locale } = useI18n()
|
||||
|
||||
const el = ref<HTMLElement>()
|
||||
const picker = ref<Picker>()
|
||||
const colorMode = useColorMode()
|
||||
|
||||
async function openEmojiPicker() {
|
||||
await updateCustomEmojis()
|
||||
|
||||
if (picker.value) {
|
||||
picker.value.update({
|
||||
theme: colorMode,
|
||||
custom: customEmojisData.value,
|
||||
})
|
||||
}
|
||||
else {
|
||||
const [Picker, dataPromise, i18n] = await Promise.all([
|
||||
import('emoji-mart').then(({ Picker }) => Picker),
|
||||
import('@emoji-mart/data/sets/14/twitter.json').then((r: any) => r.default).catch(() => {}),
|
||||
importEmojiLang(locale.value.split('-')[0]),
|
||||
])
|
||||
|
||||
picker.value = new Picker({
|
||||
data: () => dataPromise,
|
||||
onEmojiSelect({ native, src, alt, name }: any) {
|
||||
if (native)
|
||||
emit('select', native)
|
||||
else
|
||||
emit('selectCustom', { src, alt, 'data-emoji-id': name })
|
||||
},
|
||||
set: 'twitter',
|
||||
theme: colorMode,
|
||||
custom: customEmojisData.value,
|
||||
i18n,
|
||||
})
|
||||
}
|
||||
await nextTick()
|
||||
// TODO: custom picker
|
||||
el.value?.appendChild(picker.value as any as HTMLElement)
|
||||
}
|
||||
|
||||
function hideEmojiPicker() {
|
||||
if (picker.value)
|
||||
el.value?.removeChild(picker.value as any as HTMLElement)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CommonTooltip :content="$t('tooltip.add_emojis')">
|
||||
<VDropdown
|
||||
auto-boundary-max-size
|
||||
@apply-show="openEmojiPicker()"
|
||||
@apply-hide="hideEmojiPicker()"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<template #popper>
|
||||
<div ref="el" min-w-10 min-h-10 />
|
||||
</template>
|
||||
</VDropdown>
|
||||
</CommonTooltip>
|
||||
</template>
|
|
@ -1,78 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import Fuse from 'fuse.js'
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
const userSettings = useUserSettings()
|
||||
|
||||
const languageKeyword = ref('')
|
||||
|
||||
const fuse = new Fuse(languagesNameList, {
|
||||
keys: ['code', 'nativeName', 'name'],
|
||||
shouldSort: true,
|
||||
})
|
||||
|
||||
const languages = computed(() =>
|
||||
languageKeyword.value.trim()
|
||||
? fuse.search(languageKeyword.value).map(r => r.item)
|
||||
: [...languagesNameList].filter(entry => !userSettings.value.disabledTranslationLanguages.includes(entry.code)).sort(({ code: a }, { code: b }) => {
|
||||
// Put English on the top
|
||||
if (a === 'en')
|
||||
return -1
|
||||
|
||||
return a === modelValue.value ? -1 : b === modelValue.value ? 1 : a.localeCompare(b)
|
||||
}),
|
||||
)
|
||||
|
||||
const preferredLanguages = computed(() => {
|
||||
const result = []
|
||||
for (const langCode of userSettings.value.disabledTranslationLanguages) {
|
||||
const completeLang = languagesNameList.find(listEntry => listEntry.code === langCode)
|
||||
if (completeLang)
|
||||
result.push(completeLang)
|
||||
}
|
||||
return result
|
||||
},
|
||||
|
||||
)
|
||||
|
||||
function chooseLanguage(language: string) {
|
||||
modelValue.value = language
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div relative of-x-hidden>
|
||||
<div p2>
|
||||
<input
|
||||
v-model="languageKeyword"
|
||||
:placeholder="t('language.search')"
|
||||
p2 border-rounded w-full bg-transparent
|
||||
outline-none border="~ base"
|
||||
>
|
||||
</div>
|
||||
<div max-h-40vh overflow-auto>
|
||||
<template v-if="!languageKeyword.trim()">
|
||||
<CommonDropdownItem
|
||||
v-for="{ code, nativeName, name } in preferredLanguages"
|
||||
:key="code"
|
||||
:text="nativeName"
|
||||
:description="name"
|
||||
:checked="code === modelValue"
|
||||
@click="chooseLanguage(code)"
|
||||
/>
|
||||
<hr class="border-base ">
|
||||
</template>
|
||||
|
||||
<CommonDropdownItem
|
||||
v-for="{ code, nativeName, name } in languages"
|
||||
:key="code"
|
||||
:text="nativeName"
|
||||
:description="name"
|
||||
:checked="code === modelValue"
|
||||
@click="chooseLanguage(code)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -1,45 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
const { draftKey, draftItemIndex } = defineProps<{
|
||||
draftKey: string
|
||||
draftItemIndex: number
|
||||
}>()
|
||||
|
||||
const { threadIsActive, addThreadItem, threadItems, removeThreadItem } = useThreadComposer(draftKey)
|
||||
|
||||
const isRemovableItem = computed(() => threadIsActive.value && draftItemIndex < threadItems.value.length - 1)
|
||||
|
||||
function addOrRemoveItem() {
|
||||
if (isRemovableItem.value)
|
||||
removeThreadItem(draftItemIndex)
|
||||
|
||||
else
|
||||
addThreadItem()
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const label = computed(() => {
|
||||
if (!isRemovableItem.value && draftItemIndex === 0)
|
||||
return t('tooltip.start_thread')
|
||||
|
||||
return isRemovableItem.value ? t('tooltip.remove_thread_item') : t('tooltip.add_thread_item')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex flex-row rounded-3 :class="{ 'bg-border': threadIsActive }">
|
||||
<div
|
||||
v-if="threadIsActive" dir="ltr" pointer-events-none pe-1 pt-2 pl-2 text-sm tabular-nums text-secondary flex
|
||||
gap="0.5"
|
||||
>
|
||||
{{ draftItemIndex + 1 }}<span text-secondary-light>/</span><span text-secondary-light>{{ threadItems.length
|
||||
}}</span>
|
||||
</div>
|
||||
<CommonTooltip placement="top" :content="label">
|
||||
<button btn-action-icon :aria-label="label" @click="addOrRemoveItem">
|
||||
<div v-if="isRemovableItem" i-ri:chat-delete-line />
|
||||
<div v-else i-ri:chat-new-line />
|
||||
</button>
|
||||
</CommonTooltip>
|
||||
</div>
|
||||
</template>
|
|
@ -1,633 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { DraftItem } from '#shared/types'
|
||||
import type { mastodon } from 'masto'
|
||||
import { EditorContent } from '@tiptap/vue-3'
|
||||
import stringLength from 'string-length'
|
||||
|
||||
const {
|
||||
threadComposer,
|
||||
draftKey,
|
||||
draftItemIndex,
|
||||
expanded = false,
|
||||
placeholder,
|
||||
initial = getDefaultDraftItem,
|
||||
} = defineProps<{
|
||||
draftKey: string
|
||||
draftItemIndex: number
|
||||
initial?: () => DraftItem
|
||||
threadComposer?: ReturnType<typeof useThreadComposer>
|
||||
placeholder?: string
|
||||
inReplyToId?: string
|
||||
inReplyToVisibility?: mastodon.v1.StatusVisibility
|
||||
expanded?: boolean
|
||||
dialogLabelledBy?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(evt: 'published', status: mastodon.v1.Status): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { threadItems, threadIsActive, publishThread, threadIsSending } = threadComposer ?? useThreadComposer(draftKey)
|
||||
|
||||
const draft = computed({
|
||||
get: () => threadItems.value[draftItemIndex],
|
||||
set: (updatedDraft: DraftItem) => {
|
||||
threadItems.value[draftItemIndex] = updatedDraft
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const isFinalItemOfThread = computed(() => draftItemIndex === threadItems.value.length - 1)
|
||||
|
||||
const {
|
||||
isExceedingAttachmentLimit,
|
||||
isUploading,
|
||||
failedAttachments,
|
||||
isOverDropZone,
|
||||
uploadAttachments,
|
||||
pickAttachments,
|
||||
setDescription,
|
||||
removeAttachment,
|
||||
dropZoneRef,
|
||||
} = useUploadMediaAttachment(draft)
|
||||
|
||||
const { shouldExpanded, isExpanded, isSending, isPublishDisabled, publishDraft, failedMessages, preferredLanguage, publishSpoilerText } = usePublish(
|
||||
{
|
||||
draftItem: draft,
|
||||
...{ expanded: toRef(() => expanded), isUploading, initialDraft: initial, isPartOfThread: false },
|
||||
},
|
||||
)
|
||||
|
||||
const { editor } = useTiptap({
|
||||
content: computed({
|
||||
get: () => draft.value.params.status,
|
||||
set: (newVal) => {
|
||||
draft.value.params.status = newVal
|
||||
draft.value.lastUpdated = Date.now()
|
||||
},
|
||||
}),
|
||||
placeholder: computed(() => placeholder ?? draft.value.params.inReplyToId ? t('placeholder.replying') : t('placeholder.default_1')),
|
||||
autofocus: shouldExpanded.value,
|
||||
onSubmit: publish,
|
||||
onFocus() {
|
||||
if (!isExpanded && draft.value.initialText) {
|
||||
editor.value?.chain().insertContent(`${draft.value.initialText} `).focus('end').run()
|
||||
draft.value.initialText = ''
|
||||
}
|
||||
isExpanded.value = true
|
||||
},
|
||||
onPaste: handlePaste,
|
||||
})
|
||||
|
||||
function trimPollOptions() {
|
||||
const indexLastNonEmpty = draft.value.params.poll!.options.findLastIndex(option => option.trim().length > 0)
|
||||
const trimmedOptions = draft.value.params.poll!.options.slice(0, indexLastNonEmpty + 1)
|
||||
|
||||
if (currentInstance.value?.configuration
|
||||
&& trimmedOptions.length >= currentInstance.value?.configuration?.polls.maxOptions) {
|
||||
draft.value.params.poll!.options = trimmedOptions
|
||||
}
|
||||
else {
|
||||
draft.value.params.poll!.options = [...trimmedOptions, '']
|
||||
}
|
||||
}
|
||||
|
||||
function editPollOptionDraft(event: Event, index: number) {
|
||||
draft.value.params.poll!.options = Object.assign(draft.value.params.poll!.options.slice(), { [index]: (event.target as HTMLInputElement).value })
|
||||
|
||||
trimPollOptions()
|
||||
}
|
||||
|
||||
function deletePollOption(index: number) {
|
||||
const newPollOptions = draft.value.params.poll!.options.slice()
|
||||
newPollOptions.splice(index, 1)
|
||||
draft.value.params.poll!.options = newPollOptions
|
||||
trimPollOptions()
|
||||
}
|
||||
|
||||
const expiresInOptions = computed(() => [
|
||||
{
|
||||
seconds: 1 * 60 * 60,
|
||||
label: t('time_ago_options.hour_future', 1),
|
||||
},
|
||||
{
|
||||
seconds: 2 * 60 * 60,
|
||||
label: t('time_ago_options.hour_future', 2),
|
||||
},
|
||||
{
|
||||
seconds: 1 * 24 * 60 * 60,
|
||||
label: t('time_ago_options.day_future', 1),
|
||||
},
|
||||
{
|
||||
seconds: 2 * 24 * 60 * 60,
|
||||
label: t('time_ago_options.day_future', 2),
|
||||
},
|
||||
{
|
||||
seconds: 7 * 24 * 60 * 60,
|
||||
label: t('time_ago_options.day_future', 7),
|
||||
},
|
||||
])
|
||||
|
||||
const expiresInDefaultOptionIndex = 2
|
||||
|
||||
const characterCount = computed(() => {
|
||||
const text = htmlToText(editor.value?.getHTML() || '')
|
||||
|
||||
let length = stringLength(text)
|
||||
|
||||
// taken from https://github.com/mastodon/mastodon/blob/07f8b4d1b19f734d04e69daeb4c3421ef9767aac/app/lib/text_formatter.rb
|
||||
const linkRegex = /(https?:\/\/|xmpp:)\S+/g
|
||||
|
||||
// taken from https://github.com/mastodon/mastodon/blob/af578e/app/javascript/mastodon/features/compose/util/counter.js
|
||||
const countableMentionRegex = /(^|[^/\w])@((\w+)@[a-z0-9.-]+[a-z0-9])/gi
|
||||
|
||||
// maximum of 23 chars per link
|
||||
// https://github.com/elk-zone/elk/issues/1651
|
||||
const maxLength = 23
|
||||
|
||||
for (const [fullMatch] of text.matchAll(linkRegex))
|
||||
length -= fullMatch.length - Math.min(maxLength, fullMatch.length)
|
||||
|
||||
for (const [fullMatch, before, _handle, username] of text.matchAll(countableMentionRegex))
|
||||
length -= fullMatch.length - (before + username).length - 1 // - 1 for the @
|
||||
|
||||
if (draft.value.mentions) {
|
||||
// + 1 is needed as mentions always need a space separator at the end
|
||||
length += draft.value.mentions.map((mention) => {
|
||||
const [handle] = mention.split('@')
|
||||
return `@${handle}`
|
||||
}).join(' ').length + 1
|
||||
}
|
||||
|
||||
length += stringLength(publishSpoilerText.value)
|
||||
|
||||
return length
|
||||
})
|
||||
|
||||
const isExceedingCharacterLimit = computed(() => {
|
||||
return characterCount.value > characterLimit.value
|
||||
})
|
||||
|
||||
const postLanguageDisplay = computed(() => languagesNameList.find(i => i.code === (draft.value.params.language || preferredLanguage.value))?.nativeName)
|
||||
|
||||
const isDM = computed(() => draft.value.params.visibility === 'direct')
|
||||
|
||||
async function handlePaste(evt: ClipboardEvent) {
|
||||
const files = evt.clipboardData?.files
|
||||
if (!files || files.length === 0)
|
||||
return
|
||||
|
||||
evt.preventDefault()
|
||||
await uploadAttachments(Array.from(files))
|
||||
}
|
||||
|
||||
function insertEmoji(name: string) {
|
||||
editor.value?.chain().focus().insertEmoji(name).run()
|
||||
}
|
||||
function insertCustomEmoji(image: any) {
|
||||
editor.value?.chain().focus().insertCustomEmoji(image).run()
|
||||
}
|
||||
|
||||
async function toggleSensitive() {
|
||||
draft.value.params.sensitive = !draft.value.params.sensitive
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
if (isPublishDisabled.value || isExceedingCharacterLimit.value)
|
||||
return
|
||||
|
||||
const publishResult = await (threadIsActive.value ? publishThread() : publishDraft())
|
||||
if (publishResult) {
|
||||
if (Array.isArray(publishResult))
|
||||
failedMessages.value = publishResult
|
||||
else
|
||||
emit('published', publishResult)
|
||||
}
|
||||
}
|
||||
|
||||
useWebShareTarget(async ({ data: { data, action } }: any) => {
|
||||
if (action !== 'compose-with-shared-data')
|
||||
return
|
||||
|
||||
editor.value?.commands.focus('end')
|
||||
|
||||
for (const text of data.textParts) {
|
||||
for (const line of text.split('\n')) {
|
||||
editor.value?.commands.insertContent({
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: line }],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (data.files.length !== 0)
|
||||
await uploadAttachments(data.files)
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
focusEditor: () => {
|
||||
editor.value?.commands?.focus?.()
|
||||
},
|
||||
})
|
||||
|
||||
function stopQuestionMarkPropagation(e: KeyboardEvent) {
|
||||
if (e.key === '?')
|
||||
e.stopImmediatePropagation()
|
||||
}
|
||||
|
||||
const userSettings = useUserSettings()
|
||||
|
||||
const optimizeForLowPerformanceDevice = computed(() => getPreferences(userSettings.value, 'optimizeForLowPerformanceDevice'))
|
||||
|
||||
const languageDetectorInGlobalThis = 'LanguageDetector' in globalThis
|
||||
let supportsLanguageDetector = !optimizeForLowPerformanceDevice.value && languageDetectorInGlobalThis && await (globalThis as any).LanguageDetector.availability() === 'available'
|
||||
let languageDetector: { detect: (arg0: string, option: { signal: AbortSignal }) => any }
|
||||
// If the API is supported, but the model not loaded yet…
|
||||
if (languageDetectorInGlobalThis && !supportsLanguageDetector) {
|
||||
// …trigger the model download
|
||||
(globalThis as any).LanguageDetector.create().then((_languageDetector: { detect: (arg0: string) => any }) => {
|
||||
supportsLanguageDetector = true
|
||||
languageDetector = _languageDetector
|
||||
})
|
||||
}
|
||||
|
||||
function countLetters(text: string) {
|
||||
const segmenter = new Intl.Segmenter('und', { granularity: 'grapheme' })
|
||||
const letters = [...segmenter.segment(text)]
|
||||
return letters.length
|
||||
}
|
||||
|
||||
let detectLanguageAbortController = new AbortController()
|
||||
|
||||
const detectLanguage = useDebounceFn(async () => {
|
||||
if (!supportsLanguageDetector) {
|
||||
return
|
||||
}
|
||||
if (!languageDetector) {
|
||||
// maybe we dont want to mess with this with abort....
|
||||
languageDetector = await (globalThis as any).LanguageDetector.create()
|
||||
}
|
||||
// we stop previously running language detection process
|
||||
detectLanguageAbortController.abort()
|
||||
detectLanguageAbortController = new AbortController()
|
||||
const text = htmlToText(editor.value?.getHTML() || '')
|
||||
if (!text || countLetters(text) <= 5) {
|
||||
draft.value.params.language = preferredLanguage.value
|
||||
return
|
||||
}
|
||||
try {
|
||||
const detectedLanguage = (await languageDetector.detect(text, { signal: detectLanguageAbortController.signal }))[0].detectedLanguage
|
||||
draft.value.params.language = detectedLanguage === 'und' ? preferredLanguage.value : detectedLanguage.substring(0, 2)
|
||||
}
|
||||
catch (e) {
|
||||
// if error or abort we end up there
|
||||
if ((e as Error).name !== 'AbortError') {
|
||||
console.error(e)
|
||||
}
|
||||
draft.value.params.language = preferredLanguage.value
|
||||
}
|
||||
}, 500)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isHydrated && currentUser" flex="~ col gap-4" py3 px2 sm:px4 aria-roledescription="publish-widget">
|
||||
<template v-if="draft.editingStatus">
|
||||
<div id="state-editing" text-secondary self-center>
|
||||
{{ $t('state.editing') }}
|
||||
</div>
|
||||
</template>
|
||||
<div flex gap-3 flex-1>
|
||||
<div>
|
||||
<NuxtLink self-start :to="getAccountRoute(currentUser.account)">
|
||||
<AccountBigAvatar :account="currentUser.account" square />
|
||||
</NuxtLink>
|
||||
<div v-if="!isFinalItemOfThread" w-full h-full flex mt--3px justify-center>
|
||||
<div w-1px border="x base" mb-6 />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div w-full>
|
||||
<div flex gap-3 flex-1>
|
||||
<!-- This `w-0` style is used to avoid overflow problems in flex layouts,so don't remove it unless you know what you're doing -->
|
||||
<div
|
||||
ref="dropZoneRef" flex w-0 flex-col gap-3 flex-1 border="2 dashed transparent"
|
||||
:class="[isSending ? 'pointer-events-none' : '', isOverDropZone ? '!border-primary' : '']"
|
||||
>
|
||||
<ContentMentionGroup v-if="draft.mentions?.length && shouldExpanded" replying>
|
||||
<button
|
||||
v-for="m, i of draft.mentions" :key="m" text-primary hover:color-red
|
||||
@click="draft.mentions?.splice(i, 1)"
|
||||
>
|
||||
{{ accountToShortHandle(m) }}
|
||||
</button>
|
||||
</ContentMentionGroup>
|
||||
|
||||
<div v-if="draft.params.sensitive">
|
||||
<input
|
||||
v-model="publishSpoilerText" type="text" :placeholder="$t('placeholder.content_warning')" p2
|
||||
border-rounded w-full bg-transparent outline-none border="~ base"
|
||||
>
|
||||
</div>
|
||||
|
||||
<CommonErrorMessage v-if="failedMessages.length > 0" described-by="publish-failed">
|
||||
<header id="publish-failed" flex justify-between>
|
||||
<div flex items-center gap-x-2 font-bold>
|
||||
<div aria-hidden="true" i-ri:error-warning-fill />
|
||||
<p>{{ $t('state.publish_failed') }}</p>
|
||||
</div>
|
||||
<CommonTooltip placement="bottom" :content="$t('action.clear_publish_failed')">
|
||||
<button
|
||||
flex rounded-4 p1 hover:bg-active cursor-pointer transition-100
|
||||
:aria-label="$t('action.clear_publish_failed')" @click="failedMessages = []"
|
||||
>
|
||||
<span aria-hidden="true" w="1.75em" h="1.75em" i-ri:close-line />
|
||||
</button>
|
||||
</CommonTooltip>
|
||||
</header>
|
||||
<ol ps-2 sm:ps-1>
|
||||
<li v-for="(error, i) in failedMessages" :key="i" flex="~ col sm:row" gap-y-1 sm:gap-x-2>
|
||||
<strong>{{ i + 1 }}.</strong>
|
||||
<span>{{ error }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</CommonErrorMessage>
|
||||
|
||||
<div relative flex-1 flex flex-col :class="shouldExpanded ? 'min-h-30' : ''">
|
||||
<EditorContent
|
||||
:editor="editor" flex max-w-full
|
||||
:class="{
|
||||
'md:max-h-[calc(100vh-200px)] sm:max-h-[calc(100vh-400px)] max-h-35 of-y-auto overscroll-contain': shouldExpanded,
|
||||
'py2 px3.5 bg-dm rounded-4 me--1 ms--1 mt--1': isDM,
|
||||
}"
|
||||
@keydown="stopQuestionMarkPropagation"
|
||||
@keydown.esc.prevent="editor?.commands.blur()"
|
||||
@keyup="detectLanguage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isUploading" flex gap-1 items-center text-sm p1 text-primary>
|
||||
<div animate-spin preserve-3d>
|
||||
<div i-ri:loader-2-fill />
|
||||
</div>
|
||||
{{ $t('state.uploading') }}
|
||||
</div>
|
||||
<CommonErrorMessage
|
||||
v-else-if="failedAttachments.length > 0"
|
||||
:described-by="isExceedingAttachmentLimit ? 'upload-failed uploads-per-post' : 'upload-failed'"
|
||||
>
|
||||
<header id="upload-failed" flex justify-between>
|
||||
<div flex items-center gap-x-2 font-bold>
|
||||
<div aria-hidden="true" i-ri:error-warning-fill />
|
||||
<p>{{ $t('state.upload_failed') }}</p>
|
||||
</div>
|
||||
<CommonTooltip placement="bottom" :content="$t('action.clear_upload_failed')">
|
||||
<button
|
||||
flex rounded-4 p1 hover:bg-active cursor-pointer transition-100
|
||||
:aria-label="$t('action.clear_upload_failed')" @click="failedAttachments = []"
|
||||
>
|
||||
<span aria-hidden="true" w="1.75em" h="1.75em" i-ri:close-line />
|
||||
</button>
|
||||
</CommonTooltip>
|
||||
</header>
|
||||
<div v-if="isExceedingAttachmentLimit" id="uploads-per-post" ps-2 sm:ps-1 text-small>
|
||||
{{ $t('state.attachments_exceed_server_limit') }}
|
||||
</div>
|
||||
<ol ps-2 sm:ps-1>
|
||||
<li v-for="error in failedAttachments" :key="error[0]" flex="~ col sm:row" gap-y-1 sm:gap-x-2>
|
||||
<strong>{{ error[1] }}:</strong>
|
||||
<span>{{ error[0] }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</CommonErrorMessage>
|
||||
|
||||
<div v-if="draft.attachments.length" flex="~ col gap-2" overflow-auto>
|
||||
<PublishAttachment
|
||||
v-for="(att, idx) in draft.attachments" :key="att.id" :attachment="att"
|
||||
:dialog-labelled-by="dialogLabelledBy ?? (draft.editingStatus ? 'state-editing' : undefined)"
|
||||
@remove="removeAttachment(idx)" @set-description="setDescription(att, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div flex="~ col 1" max-w-full>
|
||||
<form v-if="isExpanded && draft.params.poll" my-4 flex="~ 1 col" gap-3 m="s--1">
|
||||
<div v-for="(option, index) in draft.params.poll.options" :key="index" flex="~ row" gap-3>
|
||||
<input
|
||||
:value="option" bg-base border="~ base" flex-1 h10 pe-4 rounded-2 w-full flex="~ row" items-center
|
||||
relative focus-within:box-shadow-outline gap-3 px-4 py-2
|
||||
:placeholder="$t('polls.option_placeholder', { current: index + 1, max: currentInstance?.configuration?.polls.maxOptions })"
|
||||
class="option-input" @input="editPollOptionDraft($event, index)"
|
||||
>
|
||||
<CommonTooltip placement="top" :content="$t('polls.remove_option')" class="delete-button">
|
||||
<button
|
||||
btn-action-icon class="hover:bg-red/75"
|
||||
:disabled="index === draft.params.poll!.options.length - 1 && (index + 1 !== currentInstance?.configuration?.polls.maxOptions || draft.params.poll!.options[index].length === 0)"
|
||||
@click.prevent="deletePollOption(index)"
|
||||
>
|
||||
<div i-ri:delete-bin-line />
|
||||
</button>
|
||||
</CommonTooltip>
|
||||
<span
|
||||
v-if="currentInstance?.configuration?.polls.maxCharactersPerOption" class="char-limit-radial"
|
||||
aspect-ratio-1 h-10
|
||||
:style="{ background: `radial-gradient(closest-side, rgba(var(--rgb-bg-base)) 79%, transparent 80% 100%), conic-gradient(${draft.params.poll!.options[index].length / currentInstance?.configuration?.polls.maxCharactersPerOption > 1 ? 'var(--c-danger)' : 'var(--c-primary)'} ${draft.params.poll!.options[index].length / currentInstance?.configuration?.polls.maxCharactersPerOption * 100}%, var(--c-primary-fade) 0)` }"
|
||||
>{{
|
||||
draft.params.poll!.options[index].length }}</span>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="shouldExpanded" flex="~ gap-1 1 wrap" m="s--1" pt-2 justify="end" max-w-full border="t base">
|
||||
<PublishEmojiPicker @select="insertEmoji" @select-custom="insertCustomEmoji">
|
||||
<button btn-action-icon :title="$t('tooltip.emojis')" :aria-label="$t('tooltip.add_emojis')">
|
||||
<div i-ri:emotion-line />
|
||||
</button>
|
||||
</PublishEmojiPicker>
|
||||
|
||||
<CommonTooltip
|
||||
v-if="draft.params.poll === undefined" placement="top" :content="$t('tooltip.add_media')"
|
||||
>
|
||||
<button btn-action-icon :aria-label="$t('tooltip.add_media')" @click="pickAttachments">
|
||||
<div i-ri:image-add-line />
|
||||
</button>
|
||||
</CommonTooltip>
|
||||
|
||||
<template v-if="draft.attachments.length === 0">
|
||||
<CommonTooltip v-if="!draft.params.poll" placement="top" :content="$t('polls.create')">
|
||||
<button
|
||||
btn-action-icon :aria-label="$t('polls.create')"
|
||||
@click="draft.params.poll = { options: [''], expiresIn: expiresInOptions[expiresInDefaultOptionIndex].seconds }"
|
||||
>
|
||||
<div i-ri:chat-poll-line />
|
||||
</button>
|
||||
</CommonTooltip>
|
||||
<div v-else rounded-full b-1 border-dark flex="~ row" gap-1>
|
||||
<CommonTooltip placement="top" :content="$t('polls.cancel')">
|
||||
<button
|
||||
btn-action-icon b-r border-dark :aria-label="$t('polls.cancel')"
|
||||
@click="draft.params.poll = undefined"
|
||||
>
|
||||
<div i-ri:close-line />
|
||||
</button>
|
||||
</CommonTooltip>
|
||||
<CommonDropdown placement="top">
|
||||
<CommonTooltip placement="top" :content="$t('polls.settings')">
|
||||
<button :aria-label="$t('polls.settings')" btn-action-icon w-12>
|
||||
<div i-ri:list-settings-line />
|
||||
<div i-ri:arrow-down-s-line text-sm text-secondary me--1 />
|
||||
</button>
|
||||
</CommonTooltip>
|
||||
<template #popper>
|
||||
<div flex="~ col" gap-1 p-2>
|
||||
<CommonCheckbox
|
||||
v-model="draft.params.poll.multiple"
|
||||
:label="draft.params.poll.multiple ? $t('polls.disallow_multiple') : $t('polls.allow_multiple')"
|
||||
px-2 gap-3 h-9 flex justify-center hover:bg-active rounded-full
|
||||
icon-checked="i-ri:checkbox-multiple-blank-line"
|
||||
icon-unchecked="i-ri:checkbox-blank-circle-line"
|
||||
/>
|
||||
<CommonCheckbox
|
||||
v-model="draft.params.poll.hideTotals"
|
||||
:label="draft.params.poll.hideTotals ? $t('polls.show_votes') : $t('polls.hide_votes')" px-2 gap-3
|
||||
h-9 flex justify-center hover:bg-active rounded-full icon-checked="i-ri:eye-close-line"
|
||||
icon-unchecked="i-ri:eye-line"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</CommonDropdown>
|
||||
<CommonDropdown placement="bottom">
|
||||
<CommonTooltip placement="top" :content="$t('polls.expiration')">
|
||||
<button :aria-label="$t('polls.expiration')" btn-action-icon w-12>
|
||||
<div i-ri:hourglass-line />
|
||||
<div i-ri:arrow-down-s-line text-sm text-secondary me--1 />
|
||||
</button>
|
||||
</CommonTooltip>
|
||||
<template #popper>
|
||||
<CommonDropdownItem
|
||||
v-for="expiresInOption in expiresInOptions" :key="expiresInOption.seconds"
|
||||
:text="expiresInOption.label" :checked="draft.params.poll!.expiresIn === expiresInOption.seconds"
|
||||
@click="draft.params.poll!.expiresIn = expiresInOption.seconds"
|
||||
/>
|
||||
</template>
|
||||
</CommonDropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<PublishEditorTools v-if="editor" :editor="editor" />
|
||||
|
||||
<div flex-auto />
|
||||
|
||||
<PublishCharacterCounter :max="characterLimit" :length="characterCount" />
|
||||
|
||||
<CommonTooltip placement="top" :content="$t('tooltip.change_language')">
|
||||
<CommonDropdown placement="bottom" auto-boundary-max-size>
|
||||
<button btn-action-icon :aria-label="$t('tooltip.change_language')" w-max mr1>
|
||||
<span v-if="postLanguageDisplay" text-secondary text-sm ml1>{{ postLanguageDisplay }}</span>
|
||||
<div v-else i-ri:translate-2 />
|
||||
<div i-ri:arrow-down-s-line text-sm text-secondary me--1 />
|
||||
</button>
|
||||
|
||||
<template #popper>
|
||||
<PublishLanguagePicker v-model="draft.params.language" min-w-80 />
|
||||
</template>
|
||||
</CommonDropdown>
|
||||
</CommonTooltip>
|
||||
|
||||
<CommonTooltip placement="top" :content="$t('tooltip.add_content_warning')">
|
||||
<button btn-action-icon :aria-label="$t('tooltip.add_content_warning')" @click="toggleSensitive">
|
||||
<div v-if="draft.params.sensitive" i-ri:alarm-warning-fill text-orange />
|
||||
<div v-else i-ri:alarm-warning-line />
|
||||
</button>
|
||||
</CommonTooltip>
|
||||
|
||||
<PublishVisibilityPicker v-model="draft.params.visibility" :editing="!!draft.editingStatus">
|
||||
<template #default="{ visibility }">
|
||||
<button
|
||||
:disabled="!!draft.editingStatus" :aria-label="$t('tooltip.change_content_visibility')"
|
||||
btn-action-icon :class="{ 'w-12': !draft.editingStatus }"
|
||||
>
|
||||
<div :class="visibility.icon" />
|
||||
<div v-if="!draft.editingStatus" i-ri:arrow-down-s-line text-sm text-secondary me--1 />
|
||||
</button>
|
||||
</template>
|
||||
</PublishVisibilityPicker>
|
||||
|
||||
<PublishThreadTools :draft-item-index="draftItemIndex" :draft-key="draftKey" />
|
||||
|
||||
<CommonTooltip
|
||||
v-if="failedMessages.length > 0" id="publish-failed-tooltip" placement="top"
|
||||
:content="$t('tooltip.publish_failed')"
|
||||
>
|
||||
<button
|
||||
btn-danger rounded-3 text-sm w-full flex="~ gap1" items-center md:w-fit
|
||||
aria-describedby="publish-failed-tooltip"
|
||||
>
|
||||
<span block>
|
||||
<div block i-carbon:face-dizzy-filled />
|
||||
</span>
|
||||
<span>{{ $t('state.publish_failed') }}</span>
|
||||
</button>
|
||||
</CommonTooltip>
|
||||
|
||||
<CommonTooltip
|
||||
v-else id="publish-tooltip" placement="top" :content="$t('tooltip.add_publishable_content')"
|
||||
:disabled="!(isPublishDisabled || isExceedingCharacterLimit)"
|
||||
>
|
||||
<button
|
||||
v-if="!threadIsActive || isFinalItemOfThread"
|
||||
btn-solid rounded-3 text-sm w-full flex="~ gap1" items-center md:w-fit class="publish-button"
|
||||
:aria-disabled="isPublishDisabled || isExceedingCharacterLimit || threadIsSending" aria-describedby="publish-tooltip"
|
||||
:disabled="isPublishDisabled || isExceedingCharacterLimit || threadIsSending"
|
||||
@click="publish"
|
||||
>
|
||||
<span v-if="isSending || threadIsSending" block animate-spin preserve-3d>
|
||||
<div block i-ri:loader-2-fill />
|
||||
</span>
|
||||
<span v-if="failedMessages.length" block>
|
||||
<div block i-carbon:face-dizzy-filled />
|
||||
</span>
|
||||
<template v-if="threadIsActive">
|
||||
<span>{{ !threadIsSending ? $t('action.publish_thread') : $t('state.publishing') }} </span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="draft.editingStatus">{{ $t('action.save_changes') }}</span>
|
||||
<span v-else-if="draft.params.inReplyToId">{{ $t('action.reply') }}</span>
|
||||
<span v-else>{{ !isSending ? $t('action.publish') : $t('state.publishing') }}</span>
|
||||
</template>
|
||||
</button>
|
||||
</CommonTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.publish-button[aria-disabled=true] {
|
||||
cursor: not-allowed;
|
||||
background-color: var(--c-bg-btn-disabled);
|
||||
color: var(--c-text-btn-disabled);
|
||||
}
|
||||
|
||||
.publish-button[aria-disabled=true]:hover {
|
||||
background-color: var(--c-bg-btn-disabled);
|
||||
color: var(--c-text-btn-disabled);
|
||||
}
|
||||
|
||||
.option-input:focus+.delete-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.option-input:not(:focus)+.delete-button+.char-limit-radial {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.char-limit-radial {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
</style>
|
|
@ -1,68 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { DraftItem } from '#shared/types'
|
||||
import { formatTimeAgo } from '@vueuse/core'
|
||||
|
||||
const route = useRoute()
|
||||
const { formatNumber } = useHumanReadableNumber()
|
||||
const timeAgoOptions = useTimeAgoOptions()
|
||||
|
||||
const draftKey = ref('home')
|
||||
|
||||
const draftKeys = computed(() => Object.keys(currentUserDrafts.value))
|
||||
const nonEmptyDrafts = computed(() => draftKeys.value
|
||||
.filter(i => i !== draftKey.value && !isEmptyDraft(currentUserDrafts.value[i]))
|
||||
.map(i => [i, currentUserDrafts.value[i]] as const),
|
||||
)
|
||||
|
||||
watchEffect(() => {
|
||||
draftKey.value = route.query.draft?.toString() || 'home'
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
clearEmptyDrafts()
|
||||
})
|
||||
|
||||
function firstDraftItemOf(drafts: DraftItem | Array<DraftItem>): DraftItem {
|
||||
if (Array.isArray(drafts))
|
||||
return drafts[0]
|
||||
return drafts
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex="~ col" pb-6>
|
||||
<div inline-flex justify-end h-8>
|
||||
<VDropdown v-if="nonEmptyDrafts.length" placement="bottom-end">
|
||||
<button btn-text flex="inline center">
|
||||
{{ $t('compose.drafts', nonEmptyDrafts.length, { named: { v: formatNumber(nonEmptyDrafts.length) } }) }} 
|
||||
<div aria-hidden="true" i-ri:arrow-down-s-line />
|
||||
</button>
|
||||
<template #popper="{ hide }">
|
||||
<div flex="~ col">
|
||||
<NuxtLink
|
||||
v-for="[key, drafts] of nonEmptyDrafts" :key="key" border="b base" text-left py2 px4
|
||||
hover:bg-active :replace="true" :to="`/compose?draft=${encodeURIComponent(key)}`" @click="hide()"
|
||||
>
|
||||
<div>
|
||||
<div flex="~ gap-1" items-center>
|
||||
<i18n-t keypath="compose.draft_title">
|
||||
<code>{{ key }}</code>
|
||||
</i18n-t>
|
||||
<span v-if="firstDraftItemOf(drafts).lastUpdated" text-secondary text-sm>
|
||||
· {{ formatTimeAgo(new Date(firstDraftItemOf(drafts).lastUpdated), timeAgoOptions) }}
|
||||
</span>
|
||||
</div>
|
||||
<div text-secondary>
|
||||
{{ htmlToText(firstDraftItemOf(drafts).params.status).slice(0, 50) }}
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
</VDropdown>
|
||||
</div>
|
||||
<div>
|
||||
<PublishWidgetList expanded class="min-h-100!" :draft-key="draftKey" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -1,46 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { DraftItem } from '#shared/types'
|
||||
import type { mastodon } from 'masto'
|
||||
|
||||
const {
|
||||
draftKey,
|
||||
initial = getDefaultDraftItem,
|
||||
expanded = false,
|
||||
} = defineProps<{
|
||||
draftKey: string
|
||||
initial?: () => DraftItem
|
||||
placeholder?: string
|
||||
inReplyToId?: string
|
||||
inReplyToVisibility?: mastodon.v1.StatusVisibility
|
||||
expanded?: boolean
|
||||
dialogLabelledBy?: string
|
||||
}>()
|
||||
|
||||
const threadComposer = useThreadComposer(draftKey, initial)
|
||||
const threadItems = computed(() => threadComposer.threadItems.value)
|
||||
|
||||
onDeactivated(() => {
|
||||
clearEmptyDrafts()
|
||||
})
|
||||
|
||||
function isFirstItem(index: number) {
|
||||
return index === 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="isHydrated && currentUser">
|
||||
<PublishWidget
|
||||
v-for="(_, index) in threadItems" :key="`${draftKey}-${index}`"
|
||||
v-bind="$attrs"
|
||||
:thread-composer="threadComposer"
|
||||
:draft-key="draftKey"
|
||||
:draft-item-index="index"
|
||||
:expanded="isFirstItem(index) ? expanded : true"
|
||||
:placeholder="placeholder"
|
||||
:dialog-labelled-by="dialogLabelledBy"
|
||||
:in-reply-to-id="isFirstItem(index) ? inReplyToId : undefined"
|
||||
:in-reply-to-visibility="inReplyToVisibility"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
|
@ -1,266 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { mastodon } from 'masto'
|
||||
import { toggleBlockAccount, toggleFollowAccount, toggleMuteAccount, useRelationship } from '~/composables/masto/relationship'
|
||||
|
||||
const { account, status } = defineProps<{
|
||||
account: mastodon.v1.Account
|
||||
status?: mastodon.v1.Status
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
}>()
|
||||
|
||||
const { client } = useMasto()
|
||||
|
||||
const step = ref('selectCategory')
|
||||
const serverRules = ref((await client.value.v2.instance.fetch()).rules || [])
|
||||
const reportReason = ref('')
|
||||
const selectedRuleIds = ref([])
|
||||
const availableStatuses = ref(status ? [status] : [])
|
||||
const selectedStatusIds = ref(status ? [status.id] : [])
|
||||
const additionalComments = ref('')
|
||||
const forwardReport = ref(false)
|
||||
|
||||
const dismissButton = ref<HTMLDivElement>()
|
||||
|
||||
loadStatuses() // Load statuses asynchronously ahead of time
|
||||
|
||||
function categoryChosen() {
|
||||
step.value = reportReason.value === 'dontlike' ? 'furtherActions' : 'selectStatuses'
|
||||
resetModal()
|
||||
}
|
||||
|
||||
async function loadStatuses() {
|
||||
if (status) {
|
||||
// Load the 5 statuses before and after the reported status
|
||||
const prevStatuses = await client.value.v1.accounts.$select(account.id).statuses.list({
|
||||
maxId: status.id,
|
||||
limit: 5,
|
||||
})
|
||||
const nextStatuses = await client.value.v1.accounts.$select(account.id).statuses.list({
|
||||
minId: status.id,
|
||||
limit: 5,
|
||||
})
|
||||
availableStatuses.value = availableStatuses.value.concat(prevStatuses)
|
||||
availableStatuses.value = availableStatuses.value.concat(nextStatuses)
|
||||
}
|
||||
else {
|
||||
// Reporting an account directly
|
||||
// Load the 10 most recent statuses
|
||||
const mostRecentStatuses = await client.value.v1.accounts.$select(account.id).statuses.list({
|
||||
limit: 10,
|
||||
})
|
||||
availableStatuses.value = mostRecentStatuses
|
||||
}
|
||||
availableStatuses.value.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
}
|
||||
|
||||
async function submitReport() {
|
||||
await client.value.v1.reports.create({
|
||||
accountId: account.id,
|
||||
statusIds: selectedStatusIds.value,
|
||||
comment: additionalComments.value,
|
||||
forward: forwardReport.value,
|
||||
category: reportReason.value === 'spam' ? 'spam' : reportReason.value === 'violation' ? 'violation' : 'other',
|
||||
ruleIds: reportReason.value === 'violation' ? selectedRuleIds.value : null,
|
||||
})
|
||||
step.value = 'furtherActions'
|
||||
resetModal()
|
||||
}
|
||||
|
||||
function unfollow() {
|
||||
emit('close')
|
||||
toggleFollowAccount(useRelationship(account).value!, account)
|
||||
}
|
||||
|
||||
function mute() {
|
||||
emit('close')
|
||||
toggleMuteAccount(useRelationship(account).value!, account)
|
||||
}
|
||||
|
||||
function block() {
|
||||
emit('close')
|
||||
toggleBlockAccount(useRelationship(account).value!, account)
|
||||
}
|
||||
|
||||
function resetModal() {
|
||||
// TODO: extract this scroll/reset logic into ModalDialog element
|
||||
dismissButton.value?.scrollIntoView() // scroll to top
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div my-8 px-3 sm:px-8 flex="~ col gap-4" relative>
|
||||
<h2 mxa text-xl>
|
||||
<i18n-t :keypath="reportReason === 'dontlike' ? 'report.limiting' : 'report.reporting'">
|
||||
<b text-primary>@{{ account.acct }}</b>
|
||||
</i18n-t>
|
||||
</h2>
|
||||
<button ref="dismissButton" btn-action-icon absolute top--8 right-0 m1 :aria-label="$t('action.close')" @click="emit('close')">
|
||||
<div i-ri:close-line />
|
||||
</button>
|
||||
|
||||
<template v-if="step === 'selectCategory'">
|
||||
<h1 mxa text-4xl mb4>
|
||||
{{ status ? $t('report.whats_wrong_post') : $t('report.whats_wrong_account') }}
|
||||
</h1>
|
||||
<p text-xl>
|
||||
{{ $t('report.select_one') }}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<input id="dontlike" v-model="reportReason" type="radio" value="dontlike">
|
||||
<label pl-2 for="dontlike" font-bold>{{ $t('report.dontlike') }}</label>
|
||||
<p pl-6>
|
||||
{{ $t('report.dontlike_desc') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input id="spam" v-model="reportReason" type="radio" value="spam">
|
||||
<label pl-2 for="spam" font-bold>{{ $t('report.spam') }}</label>
|
||||
<p pl-6>
|
||||
{{ $t('report.spam_desc') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="serverRules.length > 0">
|
||||
<input id="violation" v-model="reportReason" type="radio" value="violation">
|
||||
<label pl-2 for="violation" font-bold>{{ $t('report.violation') }}</label>
|
||||
<p v-if="reportReason === 'violation'" pl-6 pt-2 text-primary font-bold>
|
||||
{{ $t('report.select_many') }}
|
||||
</p>
|
||||
<ul pl-6>
|
||||
<li v-for="rule in serverRules" :key="rule.id" pt-2>
|
||||
<input
|
||||
:id="rule.id"
|
||||
v-model="selectedRuleIds"
|
||||
type="checkbox"
|
||||
:value="rule.id"
|
||||
:disabled="reportReason !== 'violation'"
|
||||
>
|
||||
<label pl-2 :for="rule.id">{{ rule.text }}</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input id="other" v-model="reportReason" type="radio" value="other">
|
||||
<label pl-2 for="other" font-bold>{{ $t('report.other') }}</label>
|
||||
<p pl-6>
|
||||
{{ $t('report.other_desc') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="reportReason && reportReason !== 'dontlike'">
|
||||
<h3 mt-8 mb-4 font-bold>
|
||||
{{ $t('report.anything_else') }}
|
||||
</h3>
|
||||
<textarea v-model="additionalComments" w-full h-20 p-3 border :placeholder="$t('report.additional_comments')" />
|
||||
<div v-if="getServerName(account) && getServerName(account) !== currentServer">
|
||||
<h3 mt-8 mb-2 font-bold>
|
||||
{{ $t('report.another_server') }}
|
||||
</h3>
|
||||
<p pb-1>
|
||||
{{ $t('report.forward_question') }}
|
||||
</p>
|
||||
<input id="forward" v-model="forwardReport" type="checkbox" value="rule.id">
|
||||
<label pl-2 for="forward"><b>{{ $t('report.forward', [getServerName(account)]) }}</b></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
btn-solid mxa mt-10
|
||||
:disabled="!reportReason || (reportReason === 'violation' && selectedRuleIds.length < 1)"
|
||||
@click="categoryChosen()"
|
||||
>
|
||||
{{ $t('action.next') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="step === 'selectStatuses'">
|
||||
<h1 mxa text-4xl mb4>
|
||||
{{ status ? $t('report.select_posts_other') : $t('report.select_posts') }}
|
||||
</h1>
|
||||
<p text-primary font-bold>
|
||||
{{ $t('report.select_many') }}
|
||||
</p>
|
||||
<table>
|
||||
<tr v-for="availableStatus in availableStatuses" :key="availableStatus.id">
|
||||
<td>
|
||||
<input
|
||||
:id="availableStatus.id"
|
||||
v-model="selectedStatusIds"
|
||||
type="checkbox"
|
||||
:value="availableStatus.id"
|
||||
>
|
||||
</td>
|
||||
<td>
|
||||
<label :for="availableStatus.id">
|
||||
<StatusCard :status="availableStatus" :actions="false" pointer-events-none />
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<button
|
||||
btn-solid mxa mt-5
|
||||
@click="submitReport()"
|
||||
>
|
||||
{{ $t('report.submit') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="step === 'furtherActions'">
|
||||
<h1 mxa text-4xl mb4>
|
||||
{{ reportReason === 'dontlike' ? $t('report.further_actions.limit.title') : $t('report.further_actions.report.title') }}
|
||||
</h1>
|
||||
<p text-xl>
|
||||
{{ reportReason === 'dontlike' ? $t('report.further_actions.limit.description') : $t('report.further_actions.report.description') }}
|
||||
</p>
|
||||
|
||||
<div v-if="useRelationship(account).value?.following">
|
||||
<button btn-outline mxa mt-4 mb-2 @click="unfollow()">
|
||||
<i18n-t keypath="menu.unfollow_account">
|
||||
<b>@{{ account.acct }}</b>
|
||||
</i18n-t>
|
||||
</button><br>
|
||||
{{ $t('report.unfollow_desc') }}
|
||||
</div>
|
||||
<div v-if="!useRelationship(account).value?.muting">
|
||||
<button btn-outline mxa mt-4 mb-2 @click="mute()">
|
||||
<i18n-t keypath="menu.mute_account">
|
||||
<b>@{{ account.acct }}</b>
|
||||
</i18n-t>
|
||||
</button><br>
|
||||
{{ $t('report.mute_desc') }}
|
||||
</div>
|
||||
<div v-if="!useRelationship(account).value?.blocking">
|
||||
<button btn-outline mxa mt-4 mb-2 @click="block()">
|
||||
<i18n-t keypath="menu.block_account">
|
||||
<b>@{{ account.acct }}</b>
|
||||
</i18n-t>
|
||||
</button><br>
|
||||
{{ $t('report.block_desc') }}
|
||||
</div>
|
||||
<button btn-solid mxa mt-10 @click="emit('close')">
|
||||
{{ $t('action.done') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
tr {
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
|
||||
tr:last-child {
|
||||
border: none;
|
||||
}
|
||||
|
||||
td {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
</style>
|
|
@ -1,140 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { NavButtonName } from '~/composables/settings'
|
||||
import { STORAGE_KEY_BOTTOM_NAV_BUTTONS } from '~/constants'
|
||||
|
||||
interface NavButton {
|
||||
name: NavButtonName
|
||||
label: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
const availableNavButtons: NavButton[] = [
|
||||
{ name: 'home', label: 'nav.home', icon: 'i-ri:home-5-line' },
|
||||
{ name: 'search', label: 'nav.search', icon: 'i-ri:search-line' },
|
||||
{ name: 'notification', label: 'nav.notifications', icon: 'i-ri:notification-4-line' },
|
||||
{ name: 'mention', label: 'nav.conversations', icon: 'i-ri:at-line' },
|
||||
{ name: 'favorite', label: 'nav.favourites', icon: 'i-ri:heart-line' },
|
||||
{ name: 'bookmark', label: 'nav.bookmarks', icon: 'i-ri:bookmark-line' },
|
||||
{ name: 'compose', label: 'nav.compose', icon: 'i-ri:quill-pen-line' },
|
||||
{ name: 'explore', label: 'nav.explore', icon: 'i-ri:compass-3-line' },
|
||||
{ name: 'local', label: 'nav.local', icon: 'i-ri:group-2-line' },
|
||||
{ name: 'federated', label: 'nav.federated', icon: 'i-ri:earth-line' },
|
||||
{ name: 'list', label: 'nav.lists', icon: 'i-ri:list-check' },
|
||||
{ name: 'hashtag', label: 'nav.hashtags', icon: 'i-ri:hashtag' },
|
||||
{ name: 'moreMenu', label: 'nav.more_menu', icon: 'i-ri:more-fill' },
|
||||
] as const
|
||||
|
||||
const defaultSelectedNavButtonNames = computed<NavButtonName[]>(() =>
|
||||
currentUser.value
|
||||
? ['home', 'search', 'notification', 'mention', 'moreMenu']
|
||||
: ['explore', 'local', 'federated', 'moreMenu'],
|
||||
)
|
||||
const navButtonNamesSetting = useLocalStorage<NavButtonName[]>(STORAGE_KEY_BOTTOM_NAV_BUTTONS, defaultSelectedNavButtonNames.value)
|
||||
const selectedNavButtonNames = ref<NavButtonName[]>(navButtonNamesSetting.value)
|
||||
|
||||
const selectedNavButtons = computed<NavButton[]>(() =>
|
||||
selectedNavButtonNames.value.map(name =>
|
||||
availableNavButtons.find(navButton => navButton.name === name)!,
|
||||
),
|
||||
)
|
||||
|
||||
const canSave = computed(() =>
|
||||
selectedNavButtonNames.value.length > 0
|
||||
&& selectedNavButtonNames.value.includes('moreMenu')
|
||||
&& JSON.stringify(selectedNavButtonNames.value) !== JSON.stringify(navButtonNamesSetting.value),
|
||||
)
|
||||
|
||||
function isAdded(name: NavButtonName) {
|
||||
return selectedNavButtonNames.value.includes(name)
|
||||
}
|
||||
|
||||
function append(navButtonName: NavButtonName) {
|
||||
const maxButtonNumber = 5
|
||||
if (selectedNavButtonNames.value.length < maxButtonNumber)
|
||||
selectedNavButtonNames.value = [...selectedNavButtonNames.value, navButtonName]
|
||||
}
|
||||
|
||||
function remove(navButtonName: NavButtonName) {
|
||||
selectedNavButtonNames.value = selectedNavButtonNames.value.filter(name => name !== navButtonName)
|
||||
}
|
||||
|
||||
function clear() {
|
||||
selectedNavButtonNames.value = []
|
||||
}
|
||||
|
||||
function reset() {
|
||||
selectedNavButtonNames.value = defaultSelectedNavButtonNames.value
|
||||
}
|
||||
|
||||
function save() {
|
||||
navButtonNamesSetting.value = selectedNavButtonNames.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section space-y-2>
|
||||
<h2 id="interface-bn" font-medium>
|
||||
{{ $t('settings.interface.bottom_nav') }}
|
||||
</h2>
|
||||
<form aria-labelledby="interface-bn" aria-describedby="interface-bn-desc" @submit.prevent="save">
|
||||
<p id="interface-bn-desc" pb-2>
|
||||
{{ $t('settings.interface.bottom_nav_instructions') }}
|
||||
</p>
|
||||
<!-- preview -->
|
||||
<div aria-hidden="true" flex="~ gap4 wrap" items-center select-settings h-14>
|
||||
<nav
|
||||
v-for="availableNavButton in selectedNavButtons" :key="availableNavButton.name"
|
||||
flex="~ 1" items-center justify-center text-xl
|
||||
scrollbar-hide overscroll-none
|
||||
>
|
||||
<span :class="availableNavButton.icon" />
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- button selection -->
|
||||
<div flex="~ gap4 wrap" py4>
|
||||
<button
|
||||
v-for="{ name, label, icon } in availableNavButtons"
|
||||
:key="name"
|
||||
btn-text flex="~ gap-2" items-center p2 border="~ base rounded" bg-base ws-nowrap
|
||||
:class="isAdded(name) ? 'text-secondary hover:text-second bg-auto' : ''"
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="isAdded(name)"
|
||||
@click="isAdded(name) ? remove(name) : append(name)"
|
||||
>
|
||||
<span :class="icon" />
|
||||
{{ label ? $t(label) : 'More menu' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div flex="~ col" gap-y-4 gap-x-2 py-1 sm="~ justify-end flex-row">
|
||||
<button
|
||||
btn-outline font-bold py2 full-w sm-wa flex="~ gap2 center"
|
||||
type="button"
|
||||
:disabled="selectedNavButtonNames.length === 0"
|
||||
:class="selectedNavButtonNames.length === 0 ? 'border-none' : undefined"
|
||||
@click="clear"
|
||||
>
|
||||
<span aria-hidden="true" class="block i-ri:delete-bin-line" />
|
||||
{{ $t('action.clear') }}
|
||||
</button>
|
||||
<button
|
||||
btn-outline font-bold py2 full-w sm-wa flex="~ gap2 center"
|
||||
type="reset"
|
||||
@click="reset"
|
||||
>
|
||||
<span aria-hidden="true" class="block i-ri:repeat-line" />
|
||||
{{ $t('action.reset') }}
|
||||
</button>
|
||||
<button
|
||||
btn-solid font-bold py2 full-w sm-wa flex="~ gap2 center"
|
||||
:disabled="!canSave"
|
||||
>
|
||||
<span aria-hidden="true" i-ri:save-2-fill />
|
||||
{{ $t('action.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
|
@ -1,49 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { ColorMode } from '~/composables/settings'
|
||||
|
||||
const colorMode = useColorMode()
|
||||
|
||||
function setColorMode(mode: ColorMode) {
|
||||
colorMode.preference = mode
|
||||
}
|
||||
|
||||
const modes = [
|
||||
{
|
||||
icon: 'i-ri-moon-line',
|
||||
label: 'settings.interface.dark_mode',
|
||||
mode: 'dark',
|
||||
},
|
||||
{
|
||||
icon: 'i-ri-sun-line',
|
||||
label: 'settings.interface.light_mode',
|
||||
mode: 'light',
|
||||
},
|
||||
{
|
||||
icon: 'i-ri-computer-line',
|
||||
label: 'settings.interface.system_mode',
|
||||
mode: 'system',
|
||||
},
|
||||
] as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section space-y-2>
|
||||
<h2 id="interface-cm" font-medium>
|
||||
{{ $t('settings.interface.color_mode') }}
|
||||
</h2>
|
||||
<div flex="~ gap4 wrap" w-full role="group" aria-labelledby="interface-cm">
|
||||
<button
|
||||
v-for="{ icon, label, mode } in modes"
|
||||
:key="mode"
|
||||
type="button"
|
||||
btn-text flex-1 flex="~ gap-1 center" p4 border="~ base rounded" bg-base ws-nowrap
|
||||
:aria-pressed="colorMode.preference === mode ? 'true' : 'false'"
|
||||
:class="colorMode.preference === mode ? 'pointer-events-none' : 'filter-saturate-0'"
|
||||
@click="setColorMode(mode)"
|
||||
>
|
||||
<span :class="`${icon}`" />
|
||||
{{ $t(label) }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
|
@ -1,87 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { FontSize } from '~/composables/settings'
|
||||
import { DEFAULT_FONT_SIZE } from '~/constants'
|
||||
|
||||
const userSettings = useUserSettings()
|
||||
|
||||
const sizes = (Array.from({ length: 11 })).fill(0).map((x, i) => `${10 + i}px`) as FontSize[]
|
||||
|
||||
function setFontSize(e: Event) {
|
||||
if (e.target && 'valueAsNumber' in e.target)
|
||||
userSettings.value.fontSize = sizes[e.target.valueAsNumber as number]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section space-y-2>
|
||||
<h2 id="interface-fs" font-medium>
|
||||
{{ $t('settings.interface.font_size') }}
|
||||
</h2>
|
||||
<div flex items-center space-x-4 select-settings>
|
||||
<span text-xs text-secondary>Aa</span>
|
||||
<div flex-1 relative flex items-center>
|
||||
<input
|
||||
aria-labelledby="interface-fs"
|
||||
:value="sizes.indexOf(userSettings.fontSize)"
|
||||
:aria-valuetext="`${userSettings.fontSize}${userSettings.fontSize === DEFAULT_FONT_SIZE ? ` ${$t('settings.interface.default')}` : ''}`"
|
||||
:min="0"
|
||||
:max="sizes.length - 1"
|
||||
:step="1"
|
||||
type="range"
|
||||
focus:outline-none
|
||||
appearance-none bg-transparent
|
||||
w-full cursor-pointer
|
||||
@change="setFontSize"
|
||||
>
|
||||
<div flex items-center justify-between absolute w-full pointer-events-none>
|
||||
<div
|
||||
v-for="i in sizes.length" :key="i"
|
||||
class="container-marker"
|
||||
h-3 w-3
|
||||
rounded-full bg-secondary-light
|
||||
relative
|
||||
>
|
||||
<div
|
||||
v-if="(sizes.indexOf(userSettings.fontSize)) === i - 1"
|
||||
absolute rounded-full class="-top-1 -left-1"
|
||||
bg-primary h-5 w-5
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span text-xl text-secondary>Aa</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
input:focus + div .container-marker:has(> div)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 2px solid var(--c-primary);
|
||||
border-radius: 50%;
|
||||
}
|
||||
input[type=range]::-webkit-slider-runnable-track {
|
||||
--at-apply: bg-secondary-light rounded-full h1 op60;
|
||||
}
|
||||
input[type=range]:focus::-webkit-slider-runnable-track {
|
||||
--at-apply: outline-2 outline-red;
|
||||
}
|
||||
input[type=range]::-webkit-slider-thumb {
|
||||
--at-apply: w3 h3 bg-primary -mt-1 outline outline-3 outline-primary rounded-full cursor-pointer appearance-none;
|
||||
}
|
||||
input[type=range]::-moz-range-track {
|
||||
--at-apply: bg-secondary-light rounded-full h1 op60;
|
||||
}
|
||||
input[type=range]:focus::-moz-range-track {
|
||||
--at-apply: outline-2 outline-red;
|
||||
}
|
||||
input[type=range]::-moz-range-thumb {
|
||||
--at-apply: w3 h3 bg-primary -mt-1 outline outline-3 outline-primary rounded-full cursor-pointer appearance-none border-none;
|
||||
}
|
||||
</style>
|
|
@ -1,69 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { ThemeColors } from '~/composables/settings'
|
||||
import { THEME_COLORS } from '~/constants'
|
||||
|
||||
const themes = await import('~/constants/themes.json').then((r) => {
|
||||
const map = new Map<'dark' | 'light', [string, ThemeColors][]>([['dark', []], ['light', []]])
|
||||
const themes = r.default as [string, ThemeColors][]
|
||||
for (const [key, theme] of themes) {
|
||||
map.get('dark')!.push([key, theme])
|
||||
map.get('light')!.push([key, {
|
||||
...theme,
|
||||
'--c-primary': `color-mix(in srgb, ${theme['--c-primary']}, black 25%)`,
|
||||
}])
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const settings = useUserSettings()
|
||||
|
||||
const media = useMediaQuery('(prefers-color-scheme: dark)')
|
||||
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const useThemes = shallowRef<[string, ThemeColors][]>([])
|
||||
|
||||
watch(() => colorMode.preference, (cm) => {
|
||||
const dark = cm === 'dark' || (cm === 'system' && media.value)
|
||||
const newThemes = dark ? themes.get('dark')! : themes.get('light')!
|
||||
const key = settings.value.themeColors?.['--theme-color-name'] || THEME_COLORS.defaultTheme
|
||||
for (const [k, theme] of newThemes) {
|
||||
if (k === key) {
|
||||
settings.value.themeColors = theme
|
||||
break
|
||||
}
|
||||
}
|
||||
useThemes.value = newThemes
|
||||
}, { immediate: true, flush: 'post' })
|
||||
|
||||
const currentTheme = computed(() => settings.value.themeColors?.['--theme-color-name'] || THEME_COLORS.defaultTheme)
|
||||
|
||||
function updateTheme(theme: ThemeColors) {
|
||||
settings.value.themeColors = theme
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section space-y-2>
|
||||
<h2 id="interface-tc" font-medium>
|
||||
{{ $t('settings.interface.theme_color') }}
|
||||
</h2>
|
||||
<div flex="~ gap4 wrap" p2 role="group" aria-labelledby="interface-tc">
|
||||
<button
|
||||
v-for="[key, theme] in useThemes" :key="key"
|
||||
:style="{
|
||||
'--rgb-primary': theme['--rgb-primary'],
|
||||
'background': theme['--c-primary'],
|
||||
'--local-ring-color': theme['--c-primary'],
|
||||
}"
|
||||
type="button"
|
||||
:class="currentTheme === theme['--theme-color-name'] ? 'ring-2' : 'scale-90'"
|
||||
:aria-pressed="currentTheme === theme['--theme-color-name'] ? 'true' : 'false'"
|
||||
:title="theme['--theme-color-name']"
|
||||
w-8 h-8 rounded-full transition-all
|
||||
ring="$local-ring-color offset-3 offset-$c-bg-base"
|
||||
@click="updateTheme(theme)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
|
@ -1,48 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
const { disabled = false } = defineProps<{
|
||||
icon?: string
|
||||
text?: string
|
||||
checked: boolean
|
||||
disabled?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
exact-active-class="text-primary"
|
||||
block w-full group focus:outline-none text-start
|
||||
role="checkbox" :aria-checked="checked"
|
||||
:disabled="disabled"
|
||||
:class="disabled ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
>
|
||||
<span
|
||||
w-full flex px5 py3 md:gap2 gap4 items-center
|
||||
transition-250
|
||||
:class="disabled ? '' : 'group-hover:bg-active'"
|
||||
group-focus-visible:ring="2 current"
|
||||
>
|
||||
<span flex-1 flex items-center md:gap2 gap4>
|
||||
<span
|
||||
v-if="icon" flex items-center justify-center
|
||||
flex-shrink-0
|
||||
:class="$slots.description ? 'w-12 h-12' : ''"
|
||||
>
|
||||
<slot name="icon">
|
||||
<span v-if="icon" :class="icon" md:text-size-inherit text-xl />
|
||||
</slot>
|
||||
</span>
|
||||
<span space-y-1>
|
||||
<span :class="checked ? 'text-base' : 'text-secondary'">
|
||||
<slot>
|
||||
<span>{{ text }}</span>
|
||||
</slot>
|
||||
</span>
|
||||
<span v-if="$slots.description" block text-sm text-secondary>
|
||||
<slot name="description" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span text-lg :class="checked ? 'i-ri-checkbox-line text-primary' : 'i-ri-checkbox-blank-line text-secondary'" />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
|
@ -1,306 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { mastodon } from 'masto'
|
||||
import { clamp } from '@vueuse/core'
|
||||
import { decode } from 'blurhash'
|
||||
|
||||
const {
|
||||
attachment,
|
||||
fullSize = false,
|
||||
isPreview = false,
|
||||
} = defineProps<{
|
||||
attachment: mastodon.v1.MediaAttachment
|
||||
attachments?: mastodon.v1.MediaAttachment[]
|
||||
fullSize?: boolean
|
||||
isPreview?: boolean
|
||||
}>()
|
||||
|
||||
const src = computed(() => attachment.previewUrl || attachment.url || attachment.remoteUrl!)
|
||||
const srcset = computed(() => [
|
||||
[attachment.url, attachment.meta?.original?.width],
|
||||
[attachment.remoteUrl, attachment.meta?.original?.width],
|
||||
[attachment.previewUrl, attachment.meta?.small?.width],
|
||||
].filter(([url]) => url).map(([url, size]) => `${url} ${size}w`).join(', '))
|
||||
|
||||
const rawAspectRatio = computed(() => {
|
||||
if (attachment.meta?.original?.aspect)
|
||||
return attachment.meta.original.aspect
|
||||
if (attachment.meta?.small?.aspect)
|
||||
return attachment.meta.small.aspect
|
||||
return undefined
|
||||
})
|
||||
|
||||
const aspectRatio = computed(() => {
|
||||
if (fullSize)
|
||||
return rawAspectRatio.value
|
||||
if (rawAspectRatio.value)
|
||||
return clamp(rawAspectRatio.value, 0.8, 6)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const objectPosition = computed(() => {
|
||||
const focusX = attachment.meta?.focus?.x || 0
|
||||
const focusY = attachment.meta?.focus?.y || 0
|
||||
const x = ((focusX / 2) + 0.5) * 100
|
||||
const y = ((focusY / -2) + 0.5) * 100
|
||||
|
||||
return `${x}% ${y}%`
|
||||
})
|
||||
|
||||
const typeExtsMap = {
|
||||
video: ['mp4', 'webm', 'mov', 'avi', 'mkv', 'flv', 'wmv', 'mpg', 'mpeg'],
|
||||
audio: ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'wma'],
|
||||
image: ['jpg', 'jpeg', 'png', 'svg', 'webp', 'bmp'],
|
||||
gifv: ['gifv', 'gif'],
|
||||
}
|
||||
|
||||
const type = computed(() => {
|
||||
if (attachment.type && attachment.type !== 'unknown')
|
||||
return attachment.type
|
||||
// some server returns unknown type, we need to guess it based on file extension
|
||||
for (const [type, exts] of Object.entries(typeExtsMap)) {
|
||||
if (exts.some(ext => src.value?.toLowerCase().endsWith(`.${ext}`)))
|
||||
return type
|
||||
}
|
||||
return 'unknown'
|
||||
})
|
||||
|
||||
const video = ref<HTMLVideoElement | undefined>()
|
||||
const prefersReducedMotion = usePreferredReducedMotion()
|
||||
const isAudio = computed(() => attachment.type === 'audio')
|
||||
const isVideo = computed(() => attachment.type === 'video')
|
||||
const isGif = computed(() => attachment.type === 'gifv')
|
||||
|
||||
const enableAutoplay = usePreferences('enableAutoplay')
|
||||
const unmuteVideos = usePreferences('unmuteVideos')
|
||||
|
||||
useIntersectionObserver(video, (entries) => {
|
||||
const ready = video.value?.dataset.ready === 'true'
|
||||
if (prefersReducedMotion.value === 'reduce' || !enableAutoplay.value) {
|
||||
if (ready && !video.value?.paused)
|
||||
video.value?.pause()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
entries.forEach((entry) => {
|
||||
if (entry.intersectionRatio <= 0.75) {
|
||||
if (ready && !video.value?.paused)
|
||||
video.value?.pause()
|
||||
}
|
||||
else {
|
||||
video.value?.play().then(() => {
|
||||
video.value!.dataset.ready = 'true'
|
||||
}).catch(noop)
|
||||
}
|
||||
})
|
||||
}, { threshold: 0.75 })
|
||||
|
||||
const userSettings = useUserSettings()
|
||||
|
||||
const shouldLoadAttachment = ref(isPreview || !getPreferences(userSettings.value, 'enableDataSaving'))
|
||||
|
||||
function loadAttachment() {
|
||||
shouldLoadAttachment.value = true
|
||||
}
|
||||
|
||||
const blurHashSrc = computed(() => {
|
||||
if (!attachment.blurhash)
|
||||
return ''
|
||||
const pixels = decode(attachment.blurhash, 32, 32)
|
||||
return getDataUrlFromArr(pixels, 32, 32)
|
||||
})
|
||||
|
||||
const videoThumbnail = ref(shouldLoadAttachment.value
|
||||
? attachment.previewUrl
|
||||
: blurHashSrc.value)
|
||||
|
||||
watch(shouldLoadAttachment, () => {
|
||||
videoThumbnail.value = shouldLoadAttachment.value
|
||||
? attachment.previewUrl
|
||||
: blurHashSrc.value
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div relative ma flex :gap="isAudio ? '2' : ''">
|
||||
<template v-if="type === 'video'">
|
||||
<button
|
||||
type="button"
|
||||
relative
|
||||
@click="!shouldLoadAttachment ? loadAttachment() : null"
|
||||
>
|
||||
<video
|
||||
ref="video"
|
||||
preload="none"
|
||||
:poster="videoThumbnail"
|
||||
:muted="!unmuteVideos"
|
||||
loop
|
||||
playsinline
|
||||
:controls="shouldLoadAttachment"
|
||||
rounded-lg
|
||||
object-cover
|
||||
fullscreen:object-contain
|
||||
:width="attachment.meta?.original?.width"
|
||||
:height="attachment.meta?.original?.height"
|
||||
:style="{
|
||||
aspectRatio,
|
||||
objectPosition,
|
||||
}"
|
||||
:class="!shouldLoadAttachment ? 'brightness-60 hover:brightness-70 transition-filter' : ''"
|
||||
>
|
||||
<source :src="attachment.url || attachment.previewUrl" type="video/mp4">
|
||||
</video>
|
||||
<span
|
||||
v-if="!shouldLoadAttachment"
|
||||
class="status-attachment-load"
|
||||
absolute
|
||||
text-sm
|
||||
text-white
|
||||
flex flex-col justify-center items-center
|
||||
gap-3 w-6 h-6
|
||||
pointer-events-none
|
||||
i-ri:video-download-line
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else-if="type === 'gifv'">
|
||||
<button
|
||||
type="button"
|
||||
relative
|
||||
@click="!shouldLoadAttachment ? loadAttachment() : openMediaPreview(attachments ? attachments : [attachment], attachments?.indexOf(attachment) || 0)"
|
||||
>
|
||||
<video
|
||||
ref="video"
|
||||
preload="none"
|
||||
:poster="videoThumbnail"
|
||||
:muted="!unmuteVideos"
|
||||
loop
|
||||
playsinline
|
||||
rounded-lg
|
||||
object-cover
|
||||
:width="attachment.meta?.original?.width"
|
||||
:height="attachment.meta?.original?.height"
|
||||
:style="{
|
||||
aspectRatio,
|
||||
objectPosition,
|
||||
}"
|
||||
>
|
||||
<source :src="attachment.url || attachment.previewUrl" type="video/mp4">
|
||||
</video>
|
||||
<span
|
||||
v-if="!shouldLoadAttachment"
|
||||
class="status-attachment-load"
|
||||
absolute
|
||||
text-sm
|
||||
text-white
|
||||
flex flex-col justify-center items-center
|
||||
gap-3 w-6 h-6
|
||||
pointer-events-none
|
||||
i-ri:video-download-line
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else-if="type === 'audio'">
|
||||
<audio controls h-15>
|
||||
<source :src="attachment.url || attachment.previewUrl" type="audio/mp3">
|
||||
</audio>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
type="button"
|
||||
focus:outline-none
|
||||
focus:ring="2 primary inset"
|
||||
rounded-lg
|
||||
h-full
|
||||
w-full
|
||||
:aria-label="$t('action.open_image_preview_dialog')"
|
||||
relative
|
||||
@click="!shouldLoadAttachment ? loadAttachment() : openMediaPreview(attachments ? attachments : [attachment], attachments?.indexOf(attachment) || 0)"
|
||||
>
|
||||
<CommonBlurhash
|
||||
:blurhash="attachment.blurhash || ''"
|
||||
class="status-attachment-image"
|
||||
:src="src"
|
||||
:srcset="srcset"
|
||||
:width="attachment.meta?.original?.width"
|
||||
:height="attachment.meta?.original?.height"
|
||||
:alt="attachment.description ?? 'Image'"
|
||||
:style="{
|
||||
aspectRatio,
|
||||
objectPosition,
|
||||
}"
|
||||
:should-load-image="shouldLoadAttachment"
|
||||
rounded-lg
|
||||
h-full
|
||||
w-full
|
||||
object-cover
|
||||
:draggable="shouldLoadAttachment"
|
||||
:class="!shouldLoadAttachment ? 'brightness-60 hover:brightness-70 transition-filter' : ''"
|
||||
/>
|
||||
<span
|
||||
v-if="!shouldLoadAttachment"
|
||||
class="status-attachment-load"
|
||||
absolute
|
||||
text-sm
|
||||
text-white
|
||||
flex flex-col justify-center items-center
|
||||
gap-3 w-6 h-6
|
||||
pointer-events-none
|
||||
i-ri:file-download-line
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
<div
|
||||
:class="isAudio ? [] : [
|
||||
'absolute left-2',
|
||||
isVideo ? 'top-2' : 'bottom-2',
|
||||
]"
|
||||
flex gap-col-2
|
||||
>
|
||||
<VDropdown v-if="attachment.description && !getPreferences(userSettings, 'hideAltIndicatorOnPosts')" :distance="6" placement="bottom-start">
|
||||
<button
|
||||
font-bold text-sm
|
||||
:class="isAudio
|
||||
? 'rounded-full h-15 w-15 btn-outline border-base text-secondary hover:bg-active hover:text-active'
|
||||
: 'rounded-1 bg-black/65 text-white hover:bg-black px1.2 py0.2'"
|
||||
>
|
||||
<div hidden>
|
||||
{{ $t('status.img_alt.read', [attachment.type]) }}
|
||||
</div>
|
||||
{{ $t('status.img_alt.ALT') }}
|
||||
</button>
|
||||
<template #popper>
|
||||
<div p4 flex flex-col gap-2 max-w-130>
|
||||
<div flex justify-between>
|
||||
<h2 font-bold text-xl text-secondary>
|
||||
{{ $t('status.img_alt.desc') }}
|
||||
</h2>
|
||||
<button v-close-popper text-sm btn-outline py0 px2 text-secondary border-base>
|
||||
{{ $t('status.img_alt.dismiss') }}
|
||||
</button>
|
||||
</div>
|
||||
<p whitespace-pre-wrap>
|
||||
{{ attachment.description }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</VDropdown>
|
||||
<div v-if="isGif && !getPreferences(userSettings, 'hideGifIndicatorOnPosts')">
|
||||
<button
|
||||
aria-hidden font-bold text-sm
|
||||
rounded-1 bg-black:65 text-white px1.2 py0.2 pointer-events-none
|
||||
>
|
||||
{{ $t('status.gif') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="postcss">
|
||||
.status-attachment-load {
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
translate: -50% -50%;
|
||||
}
|
||||
</style>
|
|
@ -1,221 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { mastodon } from 'masto'
|
||||
|
||||
const { actions = true, older, newer, hasOlder, hasNewer, main, account, ...props } = defineProps<{
|
||||
status: mastodon.v1.Status
|
||||
followedTag?: string | null
|
||||
actions?: boolean
|
||||
context?: mastodon.v2.FilterContext
|
||||
hover?: boolean
|
||||
inNotification?: boolean
|
||||
isPreview?: boolean
|
||||
|
||||
// If we know the prev and next status in the timeline, we can simplify the card
|
||||
older?: mastodon.v1.Status
|
||||
newer?: mastodon.v1.Status
|
||||
// Manual overrides
|
||||
hasOlder?: boolean
|
||||
hasNewer?: boolean
|
||||
|
||||
// When looking into a detailed view of a post, we can simplify the replying badges
|
||||
// to the main expanded post
|
||||
main?: mastodon.v1.Status
|
||||
account?: mastodon.v1.Account
|
||||
}>()
|
||||
|
||||
const userSettings = useUserSettings()
|
||||
|
||||
const status = computed(() => {
|
||||
if (props.status.reblog && (!props.status.content || props.status.content === props.status.reblog.content))
|
||||
return props.status.reblog
|
||||
return props.status
|
||||
})
|
||||
|
||||
// Use original status, avoid connecting a reblog
|
||||
const directReply = computed(() => hasNewer || (!!status.value.inReplyToId && (status.value.inReplyToId === newer?.id || status.value.inReplyToId === newer?.reblog?.id)))
|
||||
// Use reblogged status, connect it to further replies
|
||||
const connectReply = computed(() => hasOlder || status.value.id === older?.inReplyToId || status.value.id === older?.reblog?.inReplyToId)
|
||||
// Open a detailed status, the replies directly to it
|
||||
const replyToMain = computed(() => main && main.id === status.value.inReplyToId)
|
||||
|
||||
const rebloggedBy = computed(() => props.status.reblog ? props.status.account : null)
|
||||
|
||||
const statusRoute = computed(() => getStatusRoute(status.value))
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function go(evt: MouseEvent | KeyboardEvent) {
|
||||
if (evt.metaKey || evt.ctrlKey) {
|
||||
window.open(statusRoute.value.href)
|
||||
}
|
||||
else {
|
||||
cacheStatus(status.value)
|
||||
router.push(statusRoute.value)
|
||||
}
|
||||
}
|
||||
|
||||
const createdAt = useFormattedDateTime(status.value.createdAt)
|
||||
const timeAgoOptions = useTimeAgoOptions(true)
|
||||
const timeago = useTimeAgo(() => status.value.createdAt, timeAgoOptions)
|
||||
|
||||
const isSelfReply = computed(() => status.value.inReplyToAccountId === status.value.account.id)
|
||||
const collapseRebloggedBy = computed(() => rebloggedBy.value?.id === status.value.account.id)
|
||||
const isDM = computed(() => status.value.visibility === 'direct')
|
||||
const isPinned = computed(
|
||||
() =>
|
||||
!!props.status.pinned && account?.id === status.value.account.id,
|
||||
)
|
||||
|
||||
const showUpperBorder = computed(() => newer && !directReply.value)
|
||||
const showReplyTo = computed(() => !replyToMain.value && !directReply.value)
|
||||
|
||||
const forceShow = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StatusLink :status="status" :hover="hover">
|
||||
<!-- Upper border -->
|
||||
<div :h="showUpperBorder ? '1px' : '0'" w-auto bg-border mb-1 z--1 />
|
||||
|
||||
<slot name="meta">
|
||||
<!-- followed hashtag badge -->
|
||||
<div flex="~ col" justify-between>
|
||||
<div
|
||||
v-if="!!followedTag && followedTag !== ''"
|
||||
flex="~ gap2" items-center h-auto text-sm text-orange
|
||||
m="is-5" p="t-1 is-5"
|
||||
relative text-secondary ws-nowrap
|
||||
>
|
||||
<div i-ri:hashtag />
|
||||
<!-- show first hit followed tag -->
|
||||
<span>{{ followedTag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pinned status -->
|
||||
<div flex="~ col" justify-between>
|
||||
<div
|
||||
v-if="isPinned"
|
||||
flex="~ gap2" items-center h-auto text-sm text-orange
|
||||
m="is-5" p="t-1 is-5"
|
||||
relative text-secondary ws-nowrap
|
||||
>
|
||||
<div i-ri:pushpin-line />
|
||||
<span>{{ $t('status.pinned') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Line connecting to previous status -->
|
||||
<template v-if="status.inReplyToAccountId">
|
||||
<StatusReplyingTo
|
||||
v-if="showReplyTo"
|
||||
m="is-5" p="t-1 is-5"
|
||||
:status="status"
|
||||
:is-self-reply="isSelfReply"
|
||||
:class="inNotification ? 'text-secondary-light' : ''"
|
||||
/>
|
||||
<div flex="~ col gap-1" items-center pos="absolute top-0 inset-is-0" w="77px" z--1>
|
||||
<template v-if="showReplyTo">
|
||||
<div w="1px" h="0.5" border="x base" mt-3 />
|
||||
<div w="1px" h="0.5" border="x base" />
|
||||
<div w="1px" h="0.5" border="x base" />
|
||||
</template>
|
||||
<div w="1px" h-10 border="x base" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Reblog status -->
|
||||
<div flex="~ col" justify-between>
|
||||
<div
|
||||
v-if="rebloggedBy && !collapseRebloggedBy"
|
||||
flex="~" items-center
|
||||
p="t-1 b-0.5 x-1px"
|
||||
relative text-secondary ws-nowrap
|
||||
>
|
||||
<div i-ri:repeat-fill me-46px text-green w-16px h-16px class="status-boosted" />
|
||||
<div absolute top-1 ms-24px w-32px h-32px rounded-full>
|
||||
<AccountHoverWrapper :account="rebloggedBy">
|
||||
<NuxtLink :to="getAccountRoute(rebloggedBy)">
|
||||
<AccountAvatar :account="rebloggedBy" />
|
||||
</NuxtLink>
|
||||
</AccountHoverWrapper>
|
||||
</div>
|
||||
<AccountInlineInfo font-bold :account="rebloggedBy" :avatar="false" text-sm />
|
||||
</div>
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<div flex gap-3 :class="{ 'text-secondary': inNotification }">
|
||||
<template v-if="status.account.suspended && !forceShow">
|
||||
<div flex="~col 1" min-w-0>
|
||||
<p italic>
|
||||
{{ $t('status.account.suspended_message') }}
|
||||
</p>
|
||||
<div>
|
||||
<button p-0 flex="~ center" gap-2 text-sm btn-text @click="forceShow = true">
|
||||
<div i-ri:eye-line />
|
||||
<span>{{ $t('status.account.suspended_show') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Avatar -->
|
||||
<div relative>
|
||||
<div v-if="collapseRebloggedBy" absolute flex items-center justify-center top--6px px-2px py-3px rounded-full bg-base>
|
||||
<div i-ri:repeat-fill text-green w-16px h-16px />
|
||||
</div>
|
||||
<AccountHoverWrapper :account="status.account">
|
||||
<NuxtLink :to="getAccountRoute(status.account)" rounded-full>
|
||||
<AccountBigAvatar :account="status.account" />
|
||||
</NuxtLink>
|
||||
</AccountHoverWrapper>
|
||||
|
||||
<div v-if="connectReply" w-full h-full flex mt--3px justify-center>
|
||||
<div w-1px border="x base" mb-9 />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main -->
|
||||
<div flex="~ col 1" min-w-0>
|
||||
<!-- Account Info -->
|
||||
<div flex items-center space-x-1>
|
||||
<AccountHoverWrapper :account="status.account">
|
||||
<StatusAccountDetails :account="status.account" />
|
||||
</AccountHoverWrapper>
|
||||
<div flex-auto />
|
||||
<div v-show="!getPreferences(userSettings, 'zenMode')" text-sm text-secondary flex="~ row nowrap" hover:underline whitespace-nowrap>
|
||||
<AccountLockIndicator v-if="status.account.locked" me-2 />
|
||||
<AccountBotIndicator v-if="status.account.bot" me-2 />
|
||||
<div flex="~ gap1" items-center>
|
||||
<StatusVisibilityIndicator v-if="status.visibility !== 'public'" :status="status" />
|
||||
<div flex>
|
||||
<CommonTooltip :content="createdAt">
|
||||
<NuxtLink :title="status.createdAt" :href="statusRoute.href" @click.prevent="go($event)">
|
||||
<time text-sm ws-nowrap hover:underline :datetime="status.createdAt">
|
||||
{{ timeago }}
|
||||
</time>
|
||||
</NuxtLink>
|
||||
</CommonTooltip>
|
||||
<StatusEditIndicator :status="status" inline />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StatusActionsMore v-if="actions !== false" :status="status" me--2 />
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<StatusContent
|
||||
:status="status"
|
||||
:newer="newer"
|
||||
:context="context"
|
||||
:is-preview="isPreview"
|
||||
:in-notification="inNotification"
|
||||
mb2 :class="{ 'mt-2 mb1': isDM }"
|
||||
/>
|
||||
<StatusActions v-if="actions !== false" v-show="!getPreferences(userSettings, 'zenMode')" :status="status" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</StatusLink>
|
||||
</template>
|
|
@ -1,75 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { mastodon } from 'masto'
|
||||
|
||||
const { status, context } = defineProps<{
|
||||
status: mastodon.v1.Status
|
||||
newer?: mastodon.v1.Status
|
||||
context?: mastodon.v2.FilterContext | 'details'
|
||||
isPreview?: boolean
|
||||
inNotification?: boolean
|
||||
}>()
|
||||
|
||||
const isDM = computed(() => status.visibility === 'direct')
|
||||
const isDetails = computed(() => context === 'details')
|
||||
|
||||
// Content Filter logic
|
||||
const filterResult = computed(() => status.filtered?.length ? status.filtered[0] : null)
|
||||
const filter = computed(() => filterResult.value?.filter)
|
||||
|
||||
const filterPhrase = computed(() => filter.value?.title)
|
||||
const isFiltered = computed(() => status.account.id !== currentUser.value?.account.id && filterPhrase && context && context !== 'details' && !!filter.value?.context.includes(context))
|
||||
|
||||
// check spoiler text or media attachment
|
||||
// needed to handle accounts that mark all their posts as sensitive
|
||||
const spoilerTextPresent = computed(() => !!status.spoilerText && status.spoilerText.trim().length > 0)
|
||||
const hasSpoilerOrSensitiveMedia = computed(() => spoilerTextPresent.value || (status.sensitive && !!status.mediaAttachments.length))
|
||||
const isSensitiveNonSpoiler = computed(() => status.sensitive && !status.spoilerText && !!status.mediaAttachments.length)
|
||||
const hideAllMedia = computed(
|
||||
() => {
|
||||
return currentUser.value ? (getHideMediaByDefault(currentUser.value.account) && (!!status.mediaAttachments.length || !!status.card?.html)) : false
|
||||
},
|
||||
)
|
||||
const embeddedMediaPreference = usePreferences('experimentalEmbeddedMedia')
|
||||
const allowEmbeddedMedia = computed(() => status.card?.html && embeddedMediaPreference.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
space-y-3
|
||||
:class="{
|
||||
'py2 px3.5 bg-dm rounded-4 me--1': isDM,
|
||||
'ms--3.5 mt--1 ms--1': isDM && context !== 'details',
|
||||
}"
|
||||
>
|
||||
<StatusBody v-if="(!isFiltered && isSensitiveNonSpoiler) || hideAllMedia" :status="status" :newer="newer" :with-action="!isDetails" :class="isDetails ? 'text-xl' : ''" />
|
||||
<StatusSpoiler :enabled="hasSpoilerOrSensitiveMedia || isFiltered" :filter="isFiltered" :sensitive-non-spoiler="isSensitiveNonSpoiler || hideAllMedia" :is-d-m="isDM">
|
||||
<template v-if="spoilerTextPresent" #spoiler>
|
||||
<p>
|
||||
<ContentRich :content="status.spoilerText" :emojis="status.emojis" :markdown="false" />
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="filterPhrase" #spoiler>
|
||||
<p>{{ `${$t('status.filter_hidden_phrase')}: ${filterPhrase}` }}</p>
|
||||
</template>
|
||||
<StatusBody v-if="!(isSensitiveNonSpoiler || hideAllMedia)" :status="status" :newer="newer" :with-action="!isDetails" :class="isDetails ? 'text-xl' : ''" />
|
||||
<StatusTranslation :status="status" />
|
||||
<StatusPoll v-if="status.poll" :status="status" />
|
||||
<StatusMedia
|
||||
v-if="status.mediaAttachments?.length"
|
||||
:status="status"
|
||||
:is-preview="isPreview"
|
||||
/>
|
||||
<StatusPreviewCard
|
||||
v-if="status.card && !allowEmbeddedMedia"
|
||||
:card="status.card"
|
||||
:small-picture-only="status.mediaAttachments?.length > 0"
|
||||
/>
|
||||
<StatusEmbeddedMedia v-if="allowEmbeddedMedia" :status="status" />
|
||||
<StatusCard
|
||||
v-if="status.reblog"
|
||||
:status="status.reblog" border="~ rounded"
|
||||
:actions="false"
|
||||
/>
|
||||
</StatusSpoiler>
|
||||
</div>
|
||||
</template>
|
|
@ -1,105 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { mastodon } from 'masto'
|
||||
|
||||
const { status } = defineProps<{
|
||||
status: mastodon.v1.Status
|
||||
}>()
|
||||
|
||||
const vnode = computed(() => {
|
||||
if (!status.card?.html)
|
||||
return null
|
||||
const node = sanitizeEmbeddedIframe(status.card?.html)?.children[0]
|
||||
return node ? nodeToVNode(node) : null
|
||||
})
|
||||
const overlayToggle = ref(true)
|
||||
const card = ref(status.card)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="card">
|
||||
<div
|
||||
v-if="overlayToggle"
|
||||
h-80
|
||||
cursor-pointer
|
||||
relative
|
||||
>
|
||||
<div
|
||||
p-3
|
||||
absolute
|
||||
w-full
|
||||
h-full
|
||||
z-10
|
||||
rounded-lg
|
||||
style="background: linear-gradient(black, rgba(0,0,0,0.5), transparent, transparent, rgba(0,0,0,0.20))"
|
||||
>
|
||||
<NuxtLink flex flex-col gap-1 hover:underline text-xs text-light font-light target="_blank" :href="card?.url">
|
||||
<div flex gap-0.5>
|
||||
<p flex-row line-clamp-1>
|
||||
{{ card?.providerName }}<span v-if="card?.authorName"> • {{ card?.authorName }}</span>
|
||||
</p>
|
||||
<span
|
||||
flex-row
|
||||
w-4 h-4
|
||||
pointer-events-none
|
||||
i-ri:arrow-right-up-line
|
||||
/>
|
||||
</div>
|
||||
<p font-bold line-clamp-1 text-size-base>
|
||||
{{ card?.title }}
|
||||
</p>
|
||||
<p line-clamp-1>
|
||||
{{ $t('status.embedded_warning') }}
|
||||
</p>
|
||||
</NuxtLink>
|
||||
<div
|
||||
flex
|
||||
h-50
|
||||
mt-1
|
||||
justify-center
|
||||
flex-items-center
|
||||
>
|
||||
<button
|
||||
absolute
|
||||
bg-primary
|
||||
opacity-85
|
||||
rounded-full
|
||||
hover:bg-primary-active
|
||||
hover:opacity-95
|
||||
transition-all
|
||||
box-shadow-outline
|
||||
@click.stop.prevent="() => overlayToggle = !overlayToggle"
|
||||
>
|
||||
<span
|
||||
text-light
|
||||
flex flex-col
|
||||
gap-3
|
||||
w-27 h-27
|
||||
pointer-events-none
|
||||
i-ri:play-circle-line
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<CommonBlurhash
|
||||
v-if="card?.image"
|
||||
:blurhash="card.blurhash"
|
||||
:src="card.image"
|
||||
w-full
|
||||
h-full
|
||||
object-cover
|
||||
rounded-lg
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<!-- this inserts the iframe -->
|
||||
<component :is="vnode" v-if="vnode" rounded-lg h-80 />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
|
@ -1,49 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { mastodon } from 'masto'
|
||||
|
||||
const { status } = defineProps<{
|
||||
status: mastodon.v1.Status
|
||||
hover?: boolean
|
||||
}>()
|
||||
|
||||
const el = ref<HTMLElement>()
|
||||
const router = useRouter()
|
||||
const statusRoute = computed(() => getStatusRoute(status))
|
||||
|
||||
function onclick(evt: MouseEvent | KeyboardEvent) {
|
||||
const path = evt.composedPath() as HTMLElement[]
|
||||
const el = path.find(el => ['A', 'BUTTON', 'IMG', 'VIDEO'].includes(el.tagName?.toUpperCase()))
|
||||
const text = window.getSelection()?.toString()
|
||||
const isCustomEmoji = el?.parentElement?.classList.contains('custom-emoji')
|
||||
if ((!el && !text) || isCustomEmoji)
|
||||
go(evt)
|
||||
}
|
||||
|
||||
function go(evt: MouseEvent | KeyboardEvent) {
|
||||
if (evt.metaKey || evt.ctrlKey) {
|
||||
window.open(statusRoute.value.href)
|
||||
}
|
||||
else {
|
||||
cacheStatus(status)
|
||||
router.push(statusRoute.value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:id="`status-${status.id}`"
|
||||
ref="el"
|
||||
relative flex="~ col gap1"
|
||||
p="b-2 is-3 ie-4"
|
||||
:class="{ 'hover:bg-active': hover }"
|
||||
tabindex="0"
|
||||
focus:outline-none focus-visible:ring="2 primary inset"
|
||||
aria-roledescription="status-card"
|
||||
:lang="status.language ?? undefined"
|
||||
@click="onclick"
|
||||
@keydown.enter="onclick"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
|
@ -1,20 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { mastodon } from 'masto'
|
||||
|
||||
defineProps<{
|
||||
account: mastodon.v1.Account
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
max-h-2xl
|
||||
flex gap-2
|
||||
my-auto
|
||||
p-4 py-2
|
||||
light:bg-gray-3 dark:bg-gray-8
|
||||
>
|
||||
<span z-0>More from</span>
|
||||
<AccountInlineInfo :account="account" hover:bg-inherit ps-0 ms-0 />
|
||||
</div>
|
||||
</template>
|
|
@ -1,124 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { mastodon } from 'masto'
|
||||
|
||||
const { card, smallPictureOnly } = defineProps<{
|
||||
card: mastodon.v1.PreviewCard
|
||||
/** For the preview image, only the small image mode is displayed */
|
||||
smallPictureOnly?: boolean
|
||||
/** When it is root card in the list, not appear as a child card */
|
||||
root?: boolean
|
||||
}>()
|
||||
|
||||
// mastodon's default max og image width
|
||||
const ogImageWidth = 400
|
||||
|
||||
const alt = computed(() => `${card.title} - ${card.title}`)
|
||||
const isSquare = computed(() => (
|
||||
smallPictureOnly
|
||||
|| card.width === card.height
|
||||
|| Number(card.width || 0) < ogImageWidth
|
||||
|| Number(card.height || 0) < ogImageWidth / 2
|
||||
))
|
||||
const providerName = computed(() => card.providerName ? card.providerName : new URL(card.url).hostname)
|
||||
|
||||
// TODO: handle card.type: 'photo' | 'video' | 'rich';
|
||||
const cardTypeIconMap: Record<mastodon.v1.PreviewCardType, string> = {
|
||||
link: 'i-ri:profile-line',
|
||||
photo: 'i-ri:image-line',
|
||||
video: 'i-ri:play-line',
|
||||
rich: 'i-ri:profile-line',
|
||||
}
|
||||
|
||||
const userSettings = useUserSettings()
|
||||
const shouldLoadAttachment = ref(!getPreferences(userSettings.value, 'enableDataSaving'))
|
||||
|
||||
function loadAttachment() {
|
||||
shouldLoadAttachment.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink
|
||||
block
|
||||
of-hidden
|
||||
:to="card.url"
|
||||
bg-card
|
||||
hover:bg-active
|
||||
:class="{
|
||||
'flex flex-col': isSquare,
|
||||
'p-4': root,
|
||||
'rounded-lg': !root,
|
||||
}"
|
||||
target="_blank"
|
||||
external
|
||||
>
|
||||
<div :class="isSquare ? 'flex' : ''">
|
||||
<!-- image -->
|
||||
<div
|
||||
v-if="card.image"
|
||||
flex flex-col
|
||||
display-block of-hidden
|
||||
:class="{
|
||||
'sm:(min-w-32 w-32 h-32) min-w-24 w-24 h-24': isSquare,
|
||||
'w-full aspect-[1.91]': !isSquare,
|
||||
'rounded-lg': root,
|
||||
}"
|
||||
relative
|
||||
>
|
||||
<CommonBlurhash
|
||||
:blurhash="card.blurhash"
|
||||
:src="card.image"
|
||||
:width="card.width"
|
||||
:height="card.height"
|
||||
:alt="alt"
|
||||
:should-load-image="shouldLoadAttachment"
|
||||
w-full h-full object-cover
|
||||
:class="!shouldLoadAttachment ? 'brightness-60' : ''"
|
||||
/>
|
||||
<button
|
||||
v-if="!shouldLoadAttachment"
|
||||
type="button"
|
||||
absolute
|
||||
class="status-preview-card-load bg-black/64"
|
||||
p-2
|
||||
transition
|
||||
rounded
|
||||
hover:bg-black
|
||||
cursor-pointer
|
||||
@click.stop.prevent="!shouldLoadAttachment ? loadAttachment() : null"
|
||||
>
|
||||
<span
|
||||
text-sm
|
||||
text-white
|
||||
flex flex-col justify-center items-center
|
||||
gap-3 w-6 h-6
|
||||
i-ri:file-download-line
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
min-w-24 w-24 h-24 sm="min-w-32 w-32 h-32" bg="slate-500/10" flex justify-center items-center
|
||||
:class="[
|
||||
root ? 'rounded-lg' : '',
|
||||
]"
|
||||
>
|
||||
<div :class="cardTypeIconMap[card.type]" w="30%" h="30%" text-secondary />
|
||||
</div>
|
||||
<!-- description -->
|
||||
<StatusPreviewCardInfo :p="isSquare ? 'x-4' : '4'" :root="root" :card="card" :provider="providerName" />
|
||||
</div>
|
||||
<StatusPreviewCardMoreFromAuthor
|
||||
v-if="card?.authors?.[0]?.account"
|
||||
:account="card.authors[0].account"
|
||||
/>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<style lang="postcss">
|
||||
.status-preview-card-load {
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
translate: -50% -50%;
|
||||
}
|
||||
</style>
|
|
@ -1,77 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { mastodon } from 'masto'
|
||||
import { fetchAccountById } from '~/composables/cache'
|
||||
|
||||
type WatcherType = [status?: mastodon.v1.Status, v?: boolean]
|
||||
|
||||
const { status } = defineProps<{
|
||||
status: mastodon.v1.Status
|
||||
isSelfReply: boolean
|
||||
}>()
|
||||
|
||||
const link = ref()
|
||||
const targetIsVisible = ref(false)
|
||||
const isSelf = computed(() => status.inReplyToAccountId === status.account.id)
|
||||
const account = ref<mastodon.v1.Account | null | undefined>(isSelf.value ? status.account : undefined)
|
||||
|
||||
useIntersectionObserver(
|
||||
link,
|
||||
([{ intersectionRatio }]) => {
|
||||
targetIsVisible.value = intersectionRatio > 0.1
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [status, targetIsVisible.value] satisfies WatcherType,
|
||||
([newStatus, newVisible]) => {
|
||||
if (newStatus.account && newStatus.inReplyToAccountId === newStatus.account.id) {
|
||||
account.value = newStatus.account
|
||||
return
|
||||
}
|
||||
|
||||
if (!newVisible)
|
||||
return
|
||||
|
||||
const newId = newStatus.inReplyToAccountId
|
||||
|
||||
if (newId) {
|
||||
fetchAccountById(newStatus.inReplyToAccountId).then((acc) => {
|
||||
if (newId === status.inReplyToAccountId)
|
||||
account.value = acc
|
||||
})
|
||||
return
|
||||
}
|
||||
account.value = undefined
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink
|
||||
v-if="status.inReplyToId"
|
||||
ref="link"
|
||||
flex="~ gap2" items-center h-auto text-sm text-secondary
|
||||
:to="getStatusInReplyToRoute(status)"
|
||||
:title="$t('status.replying_to', [account ? getDisplayName(account) : $t('status.someone')])"
|
||||
text-blue saturate-50 hover:saturate-100
|
||||
>
|
||||
<template v-if="isSelfReply">
|
||||
<div i-ri-discuss-line text-blue />
|
||||
<span>{{ $t('status.show_full_thread') }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div i-ri-chat-1-line text-blue />
|
||||
<div ws-nowrap flex>
|
||||
<i18n-t keypath="status.replying_to">
|
||||
<template v-if="account">
|
||||
<AccountInlineInfo :account="account" :link="false" m-inline-2 />
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('status.someone') }}
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</template>
|
||||
</NuxtLink>
|
||||
</template>
|
|
@ -1,46 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
const { enabled, filter, sensitiveNonSpoiler } = defineProps<{
|
||||
enabled?: boolean
|
||||
filter?: boolean
|
||||
isDM?: boolean
|
||||
sensitiveNonSpoiler?: boolean
|
||||
}>()
|
||||
|
||||
const expandSpoilers = computed(() => {
|
||||
const expandCW = currentUser.value ? getExpandSpoilersByDefault(currentUser.value.account) : false
|
||||
const expandMedia = currentUser.value ? getExpandMediaByDefault(currentUser.value.account) : false
|
||||
|
||||
return !filter // always prevent expansion if filtered
|
||||
&& ((sensitiveNonSpoiler && expandMedia)
|
||||
|| (!sensitiveNonSpoiler && expandCW))
|
||||
})
|
||||
|
||||
const hideContent = enabled || sensitiveNonSpoiler
|
||||
const showContent = ref(expandSpoilers.value ? true : !hideContent)
|
||||
const toggleContent = useToggle(showContent)
|
||||
|
||||
watchEffect(() => {
|
||||
showContent.value = expandSpoilers.value ? true : !hideContent
|
||||
})
|
||||
function getToggleText() {
|
||||
if (sensitiveNonSpoiler)
|
||||
return 'status.spoiler_media_hidden'
|
||||
return filter ? 'status.filter_show_anyway' : 'status.spoiler_show_more'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hideContent" flex flex-col items-start>
|
||||
<div class="content-rich" p="x-4 b-2.5" text-center text-secondary w-full border="~ base" border-0 border-b-dotted border-b-3 mt-2>
|
||||
<slot name="spoiler" />
|
||||
</div>
|
||||
<div flex="~ gap-1 center" w-full :mb="isDM && !showContent ? '4' : ''" mt="-4.5">
|
||||
<button btn-text px-2 py-1 rounded-lg :bg="isDM ? 'transparent' : 'base'" flex="~ center gap-2" :class="showContent ? '' : 'filter-saturate-0 hover:filter-saturate-100'" :aria-expanded="showContent" @click="toggleContent()">
|
||||
<div v-if="showContent" i-ri:eye-line />
|
||||
<div v-else i-ri:eye-close-line />
|
||||
{{ showContent ? $t('status.spoiler_show_less') : $t(getToggleText()) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<slot v-if="!hideContent || showContent" />
|
||||
</template>
|
|
@ -1,26 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { mastodon } from 'masto'
|
||||
|
||||
const { isSupported, effectiveType } = useNetwork()
|
||||
const isSlow = computed(() => isSupported.value && effectiveType.value && ['slow-2g', '2g', '3g'].includes(effectiveType.value))
|
||||
const limit = computed(() => isSlow.value ? 10 : 30)
|
||||
|
||||
const paginator = useMastoClient().v1.timelines.home.list({ limit: limit.value })
|
||||
const stream = useStreaming(client => client.user.subscribe())
|
||||
function reorderAndFilter(items: mastodon.v1.Status[]) {
|
||||
return reorderedTimeline(items, 'home')
|
||||
}
|
||||
|
||||
let followedTags: mastodon.v1.Tag[] | undefined
|
||||
if (currentUser.value !== undefined) {
|
||||
followedTags = (await useMasto().client.value.v1.followedTags.list({ limit: 0 }))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PublishWidgetList draft-key="home" />
|
||||
<div h="1px" w-auto bg-border mb-3 />
|
||||
<TimelinePaginator :followed-tags="followedTags" v-bind="{ paginator, stream }" :preprocess="reorderAndFilter" context="home" />
|
||||
</div>
|
||||
</template>
|
|
@ -1,29 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { mastodon } from 'masto'
|
||||
import { STORAGE_KEY_LAST_ACCESSED_NOTIFICATION_ROUTE } from '~/constants'
|
||||
|
||||
const { filter } = defineProps<{
|
||||
filter?: mastodon.v1.NotificationType
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const lastAccessedNotificationRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_NOTIFICATION_ROUTE, '')
|
||||
|
||||
const options = { limit: 30, types: filter ? [filter] : [] }
|
||||
|
||||
// Default limit is 20 notifications, and servers are normally caped to 30
|
||||
const paginator = useMastoClient().v1.notifications.list(options)
|
||||
const stream = useStreaming(client => client.user.notification.subscribe())
|
||||
|
||||
lastAccessedNotificationRoute.value = route.path.replace(/\/notifications\/?/, '')
|
||||
|
||||
const { clearNotifications } = useNotifications()
|
||||
onActivated(() => {
|
||||
clearNotifications()
|
||||
lastAccessedNotificationRoute.value = route.path.replace(/\/notifications\/?/, '')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NotificationPaginator v-bind="{ paginator, stream }" />
|
||||
</template>
|
|
@ -1,69 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { mastodon } from 'masto'
|
||||
// @ts-expect-error missing types
|
||||
import { DynamicScrollerItem } from 'vue-virtual-scroller'
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
|
||||
|
||||
const { account, buffer = 10, endMessage = true, followedTags = [] } = defineProps<{
|
||||
paginator: mastodon.Paginator<mastodon.v1.Status[], mastodon.rest.v1.ListAccountStatusesParams>
|
||||
stream?: mastodon.streaming.Subscription
|
||||
context?: mastodon.v2.FilterContext
|
||||
account?: mastodon.v1.Account
|
||||
followedTags?: mastodon.v1.Tag[]
|
||||
preprocess?: (items: mastodon.v1.Status[]) => mastodon.v1.Status[]
|
||||
buffer?: number
|
||||
endMessage?: boolean | string
|
||||
}>()
|
||||
|
||||
const { formatNumber } = useHumanReadableNumber()
|
||||
const virtualScroller = usePreferences('experimentalVirtualScroller')
|
||||
|
||||
const showOriginSite = computed(() =>
|
||||
account && account.id !== currentUser.value?.account.id && getServerName(account) !== currentServer.value,
|
||||
)
|
||||
|
||||
function getFollowedTag(status: mastodon.v1.Status): string | null {
|
||||
const followedTagNames = followedTags.map(tag => tag.name)
|
||||
const followedStatusTags = status.tags.filter(tag => followedTagNames.includes(tag.name))
|
||||
return followedStatusTags.length > 0 ? followedStatusTags[0]?.name : null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CommonPaginator v-bind="{ paginator, stream, preprocess, buffer, endMessage }" :virtual-scroller="virtualScroller">
|
||||
<template #updater="{ number, update }">
|
||||
<button id="elk_show_new_items" py-4 border="b base" flex="~ col" p-3 w-full text-primary font-bold @click="update">
|
||||
{{ $t('timeline.show_new_items', number, { named: { v: formatNumber(number) } }) }}
|
||||
</button>
|
||||
</template>
|
||||
<template #default="{ item, older, newer, active }">
|
||||
<template v-if="virtualScroller">
|
||||
<DynamicScrollerItem :item="item" :active="active" tag="article">
|
||||
<StatusCard :followed-tag="getFollowedTag(item)" :status="item" :context="context" :older="older" :newer="newer" :account="account" />
|
||||
</DynamicScrollerItem>
|
||||
</template>
|
||||
<template v-else>
|
||||
<StatusCard :followed-tag="getFollowedTag(item)" :status="item" :context="context" :older="older" :newer="newer" :account="account" />
|
||||
</template>
|
||||
</template>
|
||||
<template v-if="context === 'account' " #done="{ items }">
|
||||
<div
|
||||
v-if="showOriginSite || items.length === 0"
|
||||
p5 text-secondary text-center flex flex-col items-center gap1
|
||||
>
|
||||
<template v-if="showOriginSite">
|
||||
<span italic>{{ $t('timeline.view_older_posts') }}</span>
|
||||
<NuxtLink
|
||||
:href="account!.url" target="_blank" external
|
||||
flex="~ gap-1" items-center text-primary
|
||||
hover="underline text-primary-active"
|
||||
>
|
||||
<div i-ri:external-link-fill />
|
||||
{{ $t('menu.open_in_original_site') }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<span v-else-if="items.length === 0">{{ $t('timeline.no_posts') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</CommonPaginator>
|
||||
</template>
|
|
@ -1,7 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
const paginator = useMastoClient().v1.accounts.$select(currentUser.value!.account.id).statuses.list({ pinned: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TimelinePaginator :paginator="paginator" />
|
||||
</template>
|
|
@ -1,20 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { mastodon } from 'masto'
|
||||
|
||||
const paginator = useMastoClient().v1.timelines.public.list({ limit: 30 })
|
||||
const stream = useStreaming(client => client.public.subscribe())
|
||||
function reorderAndFilter(items: mastodon.v1.Status[]) {
|
||||
return reorderedTimeline(items, 'public')
|
||||
}
|
||||
|
||||
let followedTags: mastodon.v1.Tag[] | undefined
|
||||
if (currentUser.value !== undefined) {
|
||||
followedTags = (await useMasto().client.value.v1.followedTags.list({ limit: 0 }))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<TimelinePaginator :followed-tags="followedTags" v-bind="{ paginator, stream }" :preprocess="reorderAndFilter" context="public" />
|
||||
</div>
|
||||
</template>
|
|
@ -1,20 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { mastodon } from 'masto'
|
||||
|
||||
const paginator = useMastoClient().v1.timelines.public.list({ limit: 30, local: true })
|
||||
const stream = useStreaming(client => client.public.local.subscribe())
|
||||
function reorderAndFilter(items: mastodon.v1.Status[]) {
|
||||
return reorderedTimeline(items, 'public')
|
||||
}
|
||||
|
||||
let followedTags: mastodon.v1.Tag[] | undefined
|
||||
if (currentUser.value !== undefined) {
|
||||
followedTags = (await useMasto().client.value.v1.followedTags.list({ limit: 0 }))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<TimelinePaginator :followed-tags="followedTags" v-bind="{ paginator, stream }" :preprocess="reorderAndFilter" context="public" />
|
||||
</div>
|
||||
</template>
|
|
@ -1,31 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
const { busy, oauth, singleInstanceServer } = useSignIn()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div p8 lg:flex="~ col gap2" hidden>
|
||||
<p v-if="isHydrated" text-sm>
|
||||
<i18n-t keypath="user.sign_in_notice_title">
|
||||
<strong>{{ currentServer }}</strong>
|
||||
</i18n-t>
|
||||
</p>
|
||||
<p text-sm text-secondary>
|
||||
{{ $t(singleInstanceServer ? 'user.single_instance_sign_in_desc' : 'user.sign_in_desc') }}
|
||||
</p>
|
||||
<button
|
||||
v-if="singleInstanceServer"
|
||||
flex="~ row" gap-x-2 items-center justify-center btn-solid text-center rounded-3
|
||||
:disabled="busy"
|
||||
@click="oauth()"
|
||||
>
|
||||
<span v-if="busy" aria-hidden="true" block animate animate-spin preserve-3d class="rtl-flip">
|
||||
<span block i-ri:loader-2-fill aria-hidden="true" />
|
||||
</span>
|
||||
<span v-else aria-hidden="true" block i-ri:login-circle-line class="rtl-flip" />
|
||||
{{ $t('action.sign_in') }}
|
||||
</button>
|
||||
<button v-else btn-solid rounded-3 text-center mt-2 select-none @click="openSigninDialog()">
|
||||
{{ $t('action.sign_in') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
|
@ -1,183 +0,0 @@
|
|||
import type { ElementNode, Node } from 'ultrahtml'
|
||||
import type { VNode } from 'vue'
|
||||
import type { ContentParseOptions } from './content-parse'
|
||||
import { decode } from 'tiny-decode'
|
||||
import { ELEMENT_NODE, TEXT_NODE } from 'ultrahtml'
|
||||
import { Fragment, h, isVNode } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import AccountHoverWrapper from '~/components/account/AccountHoverWrapper.vue'
|
||||
import TagHoverWrapper from '~/components/account/TagHoverWrapper.vue'
|
||||
import ContentCode from '~/components/content/ContentCode.vue'
|
||||
import ContentMentionGroup from '~/components/content/ContentMentionGroup.vue'
|
||||
import Emoji from '~/components/emoji/Emoji.vue'
|
||||
import { parseMastodonHTML } from './content-parse'
|
||||
|
||||
function getTextualAstComponents(astChildren: Node[]): string {
|
||||
return astChildren
|
||||
.filter(({ type }) => type === TEXT_NODE)
|
||||
.map(({ value }) => value)
|
||||
.reduce((accumulator, current) => accumulator + current, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw HTML to VNodes.
|
||||
*
|
||||
* @param content HTML content.
|
||||
* @param options Options.
|
||||
*/
|
||||
export function contentToVNode(
|
||||
content: string,
|
||||
options?: ContentParseOptions,
|
||||
): VNode {
|
||||
let tree = parseMastodonHTML(content, options)
|
||||
|
||||
const textContents = getTextualAstComponents(tree.children)
|
||||
|
||||
// if the username only contains emojis, we should probably show the emojis anyway to avoid a blank name
|
||||
if (options?.hideEmojis && textContents.length === 0)
|
||||
tree = parseMastodonHTML(content, { ...options, hideEmojis: false })
|
||||
|
||||
return h(Fragment, (tree.children as Node[] || []).map(n => treeToVNode(n)))
|
||||
}
|
||||
|
||||
export function nodeToVNode(node: Node): VNode | string | null {
|
||||
if (node.type === TEXT_NODE)
|
||||
return node.value
|
||||
|
||||
if (node.name === 'mention-group')
|
||||
return h(ContentMentionGroup, node.attributes, () => node.children.map(treeToVNode))
|
||||
|
||||
// add tooltip to emojis
|
||||
if (node.name === 'picture' || (node.name === 'img' && node.attributes?.alt)) {
|
||||
const props = node.attributes ?? {}
|
||||
props.as = node.name
|
||||
return h(
|
||||
Emoji,
|
||||
props,
|
||||
() => node.children.map(treeToVNode),
|
||||
)
|
||||
}
|
||||
|
||||
if ('children' in node) {
|
||||
if (node.name === 'a') {
|
||||
if (node.attributes.href?.startsWith('/') || node.attributes.href?.startsWith('.')) {
|
||||
node.attributes.to = node.attributes.href
|
||||
|
||||
const { href: _href, target: _target, ...attrs } = node.attributes
|
||||
return h(
|
||||
RouterLink as any,
|
||||
attrs,
|
||||
() => node.children.map(treeToVNode),
|
||||
)
|
||||
}
|
||||
|
||||
// fix #3122
|
||||
return h(
|
||||
node.name,
|
||||
node.attributes,
|
||||
node.children.map((n: Node) => {
|
||||
// replace span.ellipsis with bdi.ellipsis inside links
|
||||
if (n && n.type === ELEMENT_NODE && n.name !== 'bdi' && n.attributes?.class?.includes('ellipsis')) {
|
||||
const children = n.children.splice(0, n.children.length)
|
||||
const bdi = {
|
||||
...n,
|
||||
name: 'bdi',
|
||||
children,
|
||||
} satisfies ElementNode
|
||||
children.forEach((n: Node) => n.parent = bdi)
|
||||
return treeToVNode(bdi)
|
||||
}
|
||||
|
||||
return treeToVNode(n)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return h(
|
||||
node.name,
|
||||
node.attributes,
|
||||
node.children.map(treeToVNode),
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function treeToVNode(
|
||||
input: Node,
|
||||
): VNode | string | null {
|
||||
if (!input)
|
||||
return null
|
||||
|
||||
if (input.type === TEXT_NODE)
|
||||
return decode(input.value)
|
||||
|
||||
if ('children' in input) {
|
||||
const node = handleNode(input)
|
||||
if (node == null)
|
||||
return null
|
||||
if (isVNode(node))
|
||||
return node
|
||||
return nodeToVNode(node)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function addBdiNode(node: Node) {
|
||||
if (node.children.length === 1 && node.children[0].type === ELEMENT_NODE && node.children[0].name === 'bdi')
|
||||
return
|
||||
|
||||
const children = node.children.splice(0, node.children.length)
|
||||
const bdi = {
|
||||
name: 'bdi',
|
||||
parent: node,
|
||||
loc: node.loc,
|
||||
type: ELEMENT_NODE,
|
||||
attributes: {},
|
||||
children,
|
||||
} satisfies ElementNode
|
||||
children.forEach((n: Node) => n.parent = bdi)
|
||||
node.children.push(bdi)
|
||||
}
|
||||
|
||||
function handleMention(el: Node) {
|
||||
// Redirect mentions to the user page
|
||||
if (el.name === 'a' && el.attributes.class?.includes('mention')) {
|
||||
const href = el.attributes.href
|
||||
if (href) {
|
||||
const matchUser = href.match(UserLinkRE)
|
||||
if (matchUser) {
|
||||
const [, server, username] = matchUser
|
||||
const handle = `${username}@${server.replace(/(.+\.)(.+\..+)/, '$2')}`
|
||||
el.attributes.href = `/${server}/@${username}`
|
||||
addBdiNode(el)
|
||||
return h(AccountHoverWrapper, { handle, class: 'inline-block' }, () => nodeToVNode(el))
|
||||
}
|
||||
|
||||
const matchTag = href.match(TagLinkRE)
|
||||
if (matchTag) {
|
||||
const [, , tagName] = matchTag
|
||||
addBdiNode(el)
|
||||
el.attributes.href = `/${currentServer.value}/tags/${tagName}`
|
||||
return h(TagHoverWrapper, { tagName, class: 'inline-block' }, () => nodeToVNode(el))
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function handleCodeBlock(el: Node) {
|
||||
if (el.name === 'pre' && el.children[0]?.name === 'code') {
|
||||
const codeEl = el.children[0] as Node
|
||||
const classes = codeEl.attributes.class as string
|
||||
const lang = classes?.split(/\s/g).find(i => i.startsWith('language-'))?.replace('language-', '')
|
||||
const code = (codeEl.children && codeEl.children.length > 0)
|
||||
? recursiveTreeToText(codeEl)
|
||||
: ''
|
||||
return h(ContentCode, { lang, code: encodeURIComponent(code) })
|
||||
}
|
||||
}
|
||||
|
||||
function handleNode(el: Node) {
|
||||
return handleCodeBlock(el) || handleMention(el) || el
|
||||
}
|
|
@ -1,75 +0,0 @@
|
|||
import type { MaybeRefOrGetter, RemovableRef } from '@vueuse/core'
|
||||
import type { UseIDBOptions } from '@vueuse/integrations/useIDBKeyval'
|
||||
import { del, get, set, update } from '~/utils/elk-idb'
|
||||
|
||||
export interface UseAsyncIDBKeyvalReturn<T> {
|
||||
set: (value: T) => Promise<void>
|
||||
readIDB: () => Promise<T | undefined>
|
||||
}
|
||||
|
||||
export async function useAsyncIDBKeyval<T>(
|
||||
key: IDBValidKey,
|
||||
initialValue: MaybeRefOrGetter<T>,
|
||||
source: RemovableRef<T>,
|
||||
options: Omit<UseIDBOptions, 'shallow'> = {},
|
||||
): Promise<UseAsyncIDBKeyvalReturn<T>> {
|
||||
const {
|
||||
flush = 'pre',
|
||||
deep = true,
|
||||
writeDefaults = true,
|
||||
onError = (e: unknown) => {
|
||||
console.error(e)
|
||||
},
|
||||
} = options
|
||||
|
||||
const rawInit: T = toValue<T>(initialValue)
|
||||
|
||||
try {
|
||||
const rawValue = await get<T>(key)
|
||||
if (rawValue === undefined) {
|
||||
if (rawInit !== undefined && rawInit !== null && writeDefaults) {
|
||||
await set(key, rawInit)
|
||||
source.value = rawInit
|
||||
}
|
||||
}
|
||||
else {
|
||||
source.value = rawValue
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
onError(e)
|
||||
}
|
||||
|
||||
async function write(data: T) {
|
||||
try {
|
||||
if (data == null) {
|
||||
await del(key)
|
||||
}
|
||||
else {
|
||||
// IndexedDB does not support saving proxies, convert from proxy before saving
|
||||
await update(key, () => toRaw(data))
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
onError(e)
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
pause: pauseWatch,
|
||||
resume: resumeWatch,
|
||||
} = watchPausable(source, data => write(data), { flush, deep })
|
||||
|
||||
async function setData(value: T): Promise<void> {
|
||||
pauseWatch()
|
||||
try {
|
||||
await write(value)
|
||||
source.value = value
|
||||
}
|
||||
finally {
|
||||
resumeWatch()
|
||||
}
|
||||
}
|
||||
|
||||
return { set: setData, readIDB: () => get<T>(key) }
|
||||
}
|
|
@ -1,46 +0,0 @@
|
|||
import type { ComputedRef } from 'vue'
|
||||
|
||||
// TODO: consider to allow combinations similar to useMagicKeys using proxy?
|
||||
// e.g. `const magicSequence = useMagicSequence()`
|
||||
// `magicSequence['Shift+Ctrl+A']`
|
||||
// `const { Ctrl_A_B } = useMagicSequence()`
|
||||
|
||||
/**
|
||||
* source: inspired by https://github.com/vueuse/vueuse/issues/427#issuecomment-815619446
|
||||
* @param keys ordered list of keys making up the sequence
|
||||
*/
|
||||
export function useMagicSequence(keys: string[]): ComputedRef<boolean> {
|
||||
const magicKeys = useMagicKeys()
|
||||
|
||||
const success = ref(false)
|
||||
const i = ref(0)
|
||||
let down = false
|
||||
|
||||
watch(
|
||||
() => magicKeys.current,
|
||||
() => {
|
||||
if (magicKeys[keys[i.value]].value && !down) {
|
||||
down = true
|
||||
i.value += 1
|
||||
}
|
||||
else if (i.value > 0 && !magicKeys[keys[i.value - 1]].value && down) {
|
||||
down = false
|
||||
}
|
||||
else {
|
||||
i.value = 0
|
||||
down = false
|
||||
success.value = false
|
||||
}
|
||||
if (i.value >= keys.length && !down) {
|
||||
i.value = 0
|
||||
down = false
|
||||
success.value = true
|
||||
}
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
},
|
||||
)
|
||||
|
||||
return computed(() => success.value)
|
||||
}
|
|
@ -1,143 +0,0 @@
|
|||
import type { UserLogin } from '#shared/types'
|
||||
import type { Pausable } from '@vueuse/core'
|
||||
import type { mastodon } from 'masto'
|
||||
import type { Ref } from 'vue'
|
||||
import type { ElkInstance } from '../users'
|
||||
import { createRestAPIClient, createStreamingAPIClient, MastoHttpError } from 'masto'
|
||||
|
||||
export function createMasto() {
|
||||
return {
|
||||
client: shallowRef<mastodon.rest.Client>(undefined as never),
|
||||
streamingClient: shallowRef<mastodon.streaming.Client | undefined>(),
|
||||
}
|
||||
}
|
||||
export type ElkMasto = ReturnType<typeof createMasto>
|
||||
|
||||
export function useMasto() {
|
||||
return useNuxtApp().$masto as ElkMasto
|
||||
}
|
||||
export function useMastoClient() {
|
||||
return useMasto().client.value
|
||||
}
|
||||
|
||||
export function mastoLogin(masto: ElkMasto, user: Pick<UserLogin, 'server' | 'token'>) {
|
||||
const server = user.server
|
||||
const url = `https://${server}`
|
||||
const instance: ElkInstance = reactive(getInstanceCache(server) || { uri: server, accountDomain: server })
|
||||
const accessToken = user.token
|
||||
|
||||
const createStreamingClient = (streamingApiUrl: string | undefined) => {
|
||||
return streamingApiUrl ? createStreamingAPIClient({ streamingApiUrl, accessToken, implementation: globalThis.WebSocket }) : undefined
|
||||
}
|
||||
|
||||
const streamingApiUrl = instance?.configuration?.urls?.streaming
|
||||
masto.client.value = createRestAPIClient({ url, accessToken })
|
||||
masto.streamingClient.value = createStreamingClient(streamingApiUrl)
|
||||
|
||||
// Refetch instance info in the background on login
|
||||
masto.client.value.v2.instance.fetch().catch(error => new Promise<mastodon.v2.Instance>((resolve, reject) => {
|
||||
if (error instanceof MastoHttpError && error.statusCode === 404) {
|
||||
return masto.client.value.v1.instance.fetch().then((newInstance) => {
|
||||
console.warn(`Instance ${server} on version ${newInstance.version} does not support "GET /api/v2/instance" API, try converting to v2 instance... expect some errors`)
|
||||
const v2Instance = {
|
||||
...newInstance,
|
||||
domain: newInstance.uri,
|
||||
sourceUrl: '',
|
||||
usage: {
|
||||
users: {
|
||||
activeMonth: 0,
|
||||
},
|
||||
},
|
||||
icon: [],
|
||||
apiVersions: {
|
||||
mastodon: newInstance.version,
|
||||
},
|
||||
contact: {
|
||||
email: newInstance.email,
|
||||
},
|
||||
configuration: {
|
||||
...(newInstance.configuration ?? {}),
|
||||
urls: {
|
||||
streaming: newInstance.urls.streamingApi,
|
||||
},
|
||||
},
|
||||
} as unknown as mastodon.v2.Instance
|
||||
return resolve(v2Instance)
|
||||
}).catch(reject)
|
||||
}
|
||||
|
||||
return reject(error)
|
||||
})).then((newInstance) => {
|
||||
Object.assign(instance, newInstance)
|
||||
if (newInstance.configuration.urls.streaming !== streamingApiUrl)
|
||||
masto.streamingClient.value = createStreamingClient(newInstance.configuration.urls.streaming)
|
||||
|
||||
instanceStorage.value[server] = newInstance
|
||||
})
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
interface UseStreamingOptions<Controls extends boolean> {
|
||||
/**
|
||||
* Expose more controls
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
controls?: Controls
|
||||
/**
|
||||
* Connect on calling
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
immediate?: boolean
|
||||
}
|
||||
|
||||
export function useStreaming(
|
||||
cb: (client: mastodon.streaming.Client) => mastodon.streaming.Subscription,
|
||||
options: UseStreamingOptions<true>,
|
||||
): { stream: Ref<mastodon.streaming.Subscription | undefined> } & Pausable
|
||||
export function useStreaming(
|
||||
cb: (client: mastodon.streaming.Client) => mastodon.streaming.Subscription,
|
||||
options?: UseStreamingOptions<false>,
|
||||
): Ref<mastodon.streaming.Subscription | undefined>
|
||||
export function useStreaming(
|
||||
cb: (client: mastodon.streaming.Client) => mastodon.streaming.Subscription,
|
||||
{ immediate = true, controls }: UseStreamingOptions<boolean> = {},
|
||||
): ({ stream: Ref<mastodon.streaming.Subscription | undefined> } & Pausable) | Ref<mastodon.streaming.Subscription | undefined> {
|
||||
const { streamingClient } = useMasto()
|
||||
|
||||
const isActive = ref(immediate)
|
||||
const stream = ref<mastodon.streaming.Subscription>()
|
||||
|
||||
function pause() {
|
||||
isActive.value = false
|
||||
}
|
||||
|
||||
function resume() {
|
||||
isActive.value = true
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (stream.value) {
|
||||
stream.value.unsubscribe()
|
||||
stream.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
cleanup()
|
||||
if (streamingClient.value && isActive.value)
|
||||
stream.value = cb(streamingClient.value)
|
||||
})
|
||||
|
||||
if (import.meta.client && !process.test)
|
||||
useNuxtApp().$pageLifecycle.addFrozenListener(cleanup)
|
||||
|
||||
tryOnBeforeUnmount(() => isActive.value = false)
|
||||
|
||||
if (controls)
|
||||
return { stream, isActive, pause, resume }
|
||||
else
|
||||
return stream
|
||||
}
|
|
@ -1,81 +0,0 @@
|
|||
import type { mastodon } from 'masto'
|
||||
|
||||
const notifications = reactive<Record<string, undefined | [Promise<mastodon.streaming.Subscription>, string[]]>>({})
|
||||
|
||||
export function useNotifications() {
|
||||
const id = currentUser.value?.account.id
|
||||
|
||||
const { client, streamingClient } = useMasto()
|
||||
|
||||
async function clearNotifications() {
|
||||
if (!id || !notifications[id])
|
||||
return
|
||||
|
||||
const lastReadId = notifications[id]![1][0]
|
||||
notifications[id]![1] = []
|
||||
|
||||
if (lastReadId) {
|
||||
await client.value.v1.markers.create({
|
||||
notifications: { lastReadId },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function processNotifications(stream: mastodon.streaming.Subscription, id: string) {
|
||||
for await (const entry of stream) {
|
||||
if (entry.event === 'notification' && notifications[id])
|
||||
notifications[id]![1].unshift(entry.payload.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function connect(): Promise<void> {
|
||||
if (!isHydrated.value || !id || notifications[id] !== undefined || !currentUser.value?.token)
|
||||
return
|
||||
|
||||
let resolveStream: ((value: mastodon.streaming.Subscription | PromiseLike<mastodon.streaming.Subscription>) => void) | undefined
|
||||
const streamPromise = new Promise<mastodon.streaming.Subscription>(resolve => resolveStream = resolve)
|
||||
notifications[id] = [streamPromise, []]
|
||||
|
||||
await until(streamingClient).toBeTruthy()
|
||||
|
||||
const stream = streamingClient.value!.user.subscribe()
|
||||
resolveStream!(stream)
|
||||
|
||||
processNotifications(stream, id)
|
||||
|
||||
const position = await client.value.v1.markers.fetch({ timeline: ['notifications'] })
|
||||
const paginator = client.value.v1.notifications.list({ limit: 30 })
|
||||
|
||||
do {
|
||||
const result = await paginator.next()
|
||||
if (!result.done && result.value.length) {
|
||||
for (const notification of result.value) {
|
||||
if (notification.id === position.notifications.lastReadId)
|
||||
return
|
||||
notifications[id]![1].push(notification.id)
|
||||
}
|
||||
}
|
||||
else {
|
||||
break
|
||||
}
|
||||
} while (true)
|
||||
}
|
||||
|
||||
function disconnect(): void {
|
||||
if (!id || !notifications[id])
|
||||
return
|
||||
notifications[id]![0].then(stream => stream.unsubscribe())
|
||||
notifications[id] = undefined
|
||||
}
|
||||
|
||||
watch(currentUser, disconnect)
|
||||
|
||||
onHydrated(() => {
|
||||
connect()
|
||||
})
|
||||
|
||||
return {
|
||||
notifications: computed(() => id ? notifications[id]?.[1].length ?? 0 : 0),
|
||||
clearNotifications,
|
||||
}
|
||||
}
|
|
@ -1,321 +0,0 @@
|
|||
import type { DraftItem } from '#shared/types'
|
||||
import type { mastodon } from 'masto'
|
||||
import type { Ref } from 'vue'
|
||||
import { fileOpen } from 'browser-fs-access'
|
||||
|
||||
export function usePublish(options: {
|
||||
draftItem: Ref<DraftItem>
|
||||
expanded: Ref<boolean>
|
||||
isUploading: Ref<boolean>
|
||||
isPartOfThread: boolean
|
||||
initialDraft: () => DraftItem
|
||||
}) {
|
||||
const { draftItem } = options
|
||||
|
||||
const isEmpty = computed(() => isEmptyDraft([draftItem.value]))
|
||||
|
||||
const { client } = useMasto()
|
||||
const settings = useUserSettings()
|
||||
|
||||
const preferredLanguage = computed(() => (currentUser.value?.account.source.language || settings.value?.language || 'en').split('-')[0])
|
||||
|
||||
const isSending = ref(false)
|
||||
const isExpanded = ref(false)
|
||||
const failedMessages = ref<string[]>([])
|
||||
|
||||
const publishSpoilerText = computed({
|
||||
get() {
|
||||
return draftItem.value.params.sensitive ? draftItem.value.params.spoilerText : ''
|
||||
},
|
||||
set(val) {
|
||||
if (!draftItem.value.params.sensitive)
|
||||
return
|
||||
draftItem.value.params.spoilerText = val
|
||||
},
|
||||
})
|
||||
|
||||
const shouldExpanded = computed(() => options.expanded.value || isExpanded.value || !isEmpty.value)
|
||||
const isPublishDisabled = computed(() => {
|
||||
const { params, attachments } = draftItem.value
|
||||
const firstEmptyInputIndex = params.poll?.options.findIndex(option => option.trim().length === 0)
|
||||
return isEmpty.value
|
||||
|| options.isUploading.value
|
||||
|| isSending.value
|
||||
|| (attachments.length === 0 && !params.status)
|
||||
|| failedMessages.value.length > 0
|
||||
|| (attachments.length > 0 && params.poll !== null && params.poll !== undefined)
|
||||
|| ((params.poll !== null && params.poll !== undefined)
|
||||
&& (
|
||||
(firstEmptyInputIndex !== -1
|
||||
&& firstEmptyInputIndex !== params.poll.options.length - 1
|
||||
)
|
||||
|| params.poll.options.findLastIndex(option => option.trim().length > 0) + 1 < 2
|
||||
|| (new Set(params.poll.options).size !== params.poll.options.length)
|
||||
|| (currentInstance.value?.configuration?.polls.maxCharactersPerOption !== undefined
|
||||
&& params.poll.options.find(option => option.length > currentInstance.value!.configuration!.polls.maxCharactersPerOption) !== undefined
|
||||
)
|
||||
))
|
||||
})
|
||||
|
||||
watch(draftItem, () => {
|
||||
if (failedMessages.value.length > 0)
|
||||
failedMessages.value.length = 0
|
||||
}, { deep: true })
|
||||
|
||||
async function publishDraft() {
|
||||
if (isPublishDisabled.value)
|
||||
return
|
||||
|
||||
let content = htmlToText(draftItem.value.params.status || '')
|
||||
if (draftItem.value.mentions?.length)
|
||||
content = `${draftItem.value.mentions.map(i => `@${i}`).join(' ')} ${content}`
|
||||
|
||||
let poll
|
||||
|
||||
if (draftItem.value.params.poll) {
|
||||
let options = draftItem.value.params.poll.options
|
||||
|
||||
if (currentInstance.value?.configuration !== undefined
|
||||
&& (
|
||||
options.length < currentInstance.value.configuration.polls.maxOptions
|
||||
|| options[options.length - 1].trim().length === 0
|
||||
)
|
||||
) {
|
||||
options = options.slice(0, options.length - 1)
|
||||
}
|
||||
|
||||
poll = { ...draftItem.value.params.poll, options }
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...draftItem.value.params,
|
||||
spoilerText: publishSpoilerText.value,
|
||||
status: content,
|
||||
mediaIds: draftItem.value.attachments.map(a => a.id),
|
||||
language: draftItem.value.params.language || preferredLanguage.value,
|
||||
poll,
|
||||
...(isGlitchEdition.value ? { 'content-type': 'text/markdown' } : {}),
|
||||
} as mastodon.rest.v1.CreateStatusParams
|
||||
|
||||
if (import.meta.dev) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.info({
|
||||
raw: draftItem.value.params.status,
|
||||
...payload,
|
||||
})
|
||||
// eslint-disable-next-line no-alert
|
||||
const result = confirm('[DEV] Payload logged to console, do you want to publish it?')
|
||||
if (!result)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isSending.value = true
|
||||
|
||||
let status: mastodon.v1.Status
|
||||
if (!draftItem.value.editingStatus) {
|
||||
status = await client.value.v1.statuses.create(payload)
|
||||
}
|
||||
|
||||
else {
|
||||
status = await client.value.v1.statuses.$select(draftItem.value.editingStatus.id).update({
|
||||
...payload,
|
||||
mediaAttributes: draftItem.value.attachments.map(media => ({
|
||||
id: media.id,
|
||||
description: media.description,
|
||||
})),
|
||||
})
|
||||
}
|
||||
if (draftItem.value.params.inReplyToId && !options.isPartOfThread)
|
||||
navigateToStatus({ status })
|
||||
|
||||
draftItem.value = options.initialDraft()
|
||||
|
||||
return status
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
failedMessages.value.push((err as Error).message)
|
||||
}
|
||||
finally {
|
||||
isSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isSending,
|
||||
isExpanded,
|
||||
shouldExpanded,
|
||||
isPublishDisabled,
|
||||
failedMessages,
|
||||
preferredLanguage,
|
||||
publishSpoilerText,
|
||||
publishDraft,
|
||||
}
|
||||
}
|
||||
|
||||
export type MediaAttachmentUploadError = [filename: string, message: string]
|
||||
|
||||
export function useUploadMediaAttachment(draft: Ref<DraftItem>) {
|
||||
const { client } = useMasto()
|
||||
const { t } = useI18n()
|
||||
const { formatFileSize } = useFileSizeFormatter()
|
||||
|
||||
const isUploading = ref<boolean>(false)
|
||||
const isExceedingAttachmentLimit = ref<boolean>(false)
|
||||
const failedAttachments = ref<MediaAttachmentUploadError[]>([])
|
||||
const dropZoneRef = ref<HTMLDivElement>()
|
||||
|
||||
const maxPixels = computed(() => {
|
||||
return currentInstance.value?.configuration?.mediaAttachments?.imageMatrixLimit
|
||||
?? 4096 ** 2
|
||||
})
|
||||
|
||||
const loadImage = (inputFile: Blob) => new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const url = URL.createObjectURL(inputFile)
|
||||
const img = new Image()
|
||||
|
||||
img.onerror = err => reject(err)
|
||||
img.onload = () => resolve(img)
|
||||
|
||||
img.src = url
|
||||
})
|
||||
|
||||
function resizeImage(img: HTMLImageElement, type = 'image/png'): Promise<Blob | null> {
|
||||
const { width, height } = img
|
||||
|
||||
const aspectRatio = (width as number) / (height as number)
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
|
||||
const resizedWidth = canvas.width = Math.round(Math.sqrt(maxPixels.value * aspectRatio))
|
||||
const resizedHeight = canvas.height = Math.round(Math.sqrt(maxPixels.value / aspectRatio))
|
||||
|
||||
const context = canvas.getContext('2d')
|
||||
|
||||
context?.drawImage(img, 0, 0, resizedWidth, resizedHeight)
|
||||
|
||||
return new Promise((resolve) => {
|
||||
canvas.toBlob(resolve, type)
|
||||
})
|
||||
}
|
||||
|
||||
async function processImageFile(file: File) {
|
||||
try {
|
||||
const image = await loadImage(file) as HTMLImageElement
|
||||
|
||||
if (image.width * image.height > maxPixels.value)
|
||||
file = await resizeImage(image, file.type) as File
|
||||
|
||||
return file
|
||||
}
|
||||
catch (e) {
|
||||
// Resize failed, just use the original file
|
||||
console.error(e)
|
||||
return file
|
||||
}
|
||||
}
|
||||
|
||||
async function processFile(file: File) {
|
||||
if (file.type.startsWith('image/'))
|
||||
return await processImageFile(file)
|
||||
|
||||
return file
|
||||
}
|
||||
|
||||
async function uploadAttachments(files: File[]) {
|
||||
isUploading.value = true
|
||||
failedAttachments.value = []
|
||||
// TODO: display some kind of message if too many media are selected
|
||||
// DONE
|
||||
const limit = currentInstance.value!.configuration?.statuses.maxMediaAttachments || 4
|
||||
const maxVideoSize = currentInstance.value!.configuration?.mediaAttachments.videoSizeLimit || 0
|
||||
const maxImageSize = currentInstance.value!.configuration?.mediaAttachments.imageSizeLimit || 0
|
||||
for (const file of files.slice(0, limit)) {
|
||||
if (draft.value.attachments.length < limit) {
|
||||
if (file.type.startsWith('image/')) {
|
||||
if (maxImageSize > 0 && file.size > maxImageSize) {
|
||||
failedAttachments.value = [...failedAttachments.value, [file.name, t('state.attachments_limit_image_error', [formatFileSize(maxImageSize)])]]
|
||||
continue
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (maxVideoSize > 0 && file.size > maxVideoSize) {
|
||||
const key
|
||||
= file.type.startsWith('audio/')
|
||||
? 'state.attachments_limit_audio_error'
|
||||
: file.type.startsWith('video/')
|
||||
? 'state.attachments_limit_video_error'
|
||||
: 'state.attachments_limit_unknown_error'
|
||||
const errorMessage = t(key, [formatFileSize(maxVideoSize)])
|
||||
failedAttachments.value = [
|
||||
...failedAttachments.value,
|
||||
[file.name, errorMessage],
|
||||
]
|
||||
continue
|
||||
}
|
||||
}
|
||||
isExceedingAttachmentLimit.value = false
|
||||
try {
|
||||
const attachment = await client.value.v1.media.create({
|
||||
file: await processFile(file),
|
||||
})
|
||||
draft.value.attachments.push(attachment)
|
||||
}
|
||||
catch (e) {
|
||||
// TODO: add some human-readable error message, problem is that masto api will not return response code
|
||||
console.error(e)
|
||||
failedAttachments.value = [...failedAttachments.value, [file.name, (e as Error).message]]
|
||||
}
|
||||
}
|
||||
else {
|
||||
isExceedingAttachmentLimit.value = true
|
||||
failedAttachments.value = [...failedAttachments.value, [file.name, t('state.attachments_limit_error')]]
|
||||
}
|
||||
}
|
||||
isUploading.value = false
|
||||
}
|
||||
|
||||
async function pickAttachments() {
|
||||
if (import.meta.server)
|
||||
return
|
||||
const mimeTypes = currentInstance.value!.configuration?.mediaAttachments.supportedMimeTypes
|
||||
const files = await fileOpen({
|
||||
description: 'Attachments',
|
||||
multiple: true,
|
||||
mimeTypes,
|
||||
})
|
||||
await uploadAttachments(files)
|
||||
}
|
||||
|
||||
async function setDescription(att: mastodon.v1.MediaAttachment, description: string) {
|
||||
att.description = description
|
||||
if (!draft.value.editingStatus)
|
||||
await client.value.v1.media.$select(att.id).update({ description: att.description })
|
||||
}
|
||||
|
||||
function removeAttachment(index: number) {
|
||||
draft.value.attachments.splice(index, 1)
|
||||
}
|
||||
|
||||
async function onDrop(files: File[] | null) {
|
||||
if (files)
|
||||
await uploadAttachments(files)
|
||||
}
|
||||
|
||||
const { isOverDropZone } = useDropZone(dropZoneRef, onDrop)
|
||||
|
||||
return {
|
||||
isUploading,
|
||||
isExceedingAttachmentLimit,
|
||||
isOverDropZone,
|
||||
|
||||
failedAttachments,
|
||||
dropZoneRef,
|
||||
|
||||
uploadAttachments,
|
||||
pickAttachments,
|
||||
setDescription,
|
||||
removeAttachment,
|
||||
}
|
||||
}
|
|
@ -1,140 +0,0 @@
|
|||
import type { mastodon } from 'masto'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
// Batch requests for relationships when used in the UI
|
||||
// We don't want to hold to old values, so every time a Relationship is needed it
|
||||
// is requested again from the server to show the latest state
|
||||
|
||||
const requestedRelationships = new Map<string, Ref<mastodon.v1.Relationship | undefined>>()
|
||||
let timeoutHandle: NodeJS.Timeout | undefined
|
||||
|
||||
export function useRelationship(account: mastodon.v1.Account): Ref<mastodon.v1.Relationship | undefined> {
|
||||
if (!currentUser.value)
|
||||
return ref()
|
||||
|
||||
let relationship = requestedRelationships.get(account.id)
|
||||
if (relationship)
|
||||
return relationship
|
||||
|
||||
// allow batch relationship requests
|
||||
relationship = ref<mastodon.v1.Relationship | undefined>()
|
||||
requestedRelationships.set(account.id, relationship)
|
||||
if (timeoutHandle)
|
||||
clearTimeout(timeoutHandle)
|
||||
timeoutHandle = setTimeout(() => {
|
||||
timeoutHandle = undefined
|
||||
fetchRelationships()
|
||||
}, 100)
|
||||
|
||||
return relationship
|
||||
}
|
||||
|
||||
async function fetchRelationships() {
|
||||
const requested = Array.from(requestedRelationships.entries()).filter(([, r]) => !r.value)
|
||||
const relationships = await useMastoClient().v1.accounts.relationships.fetch({ id: requested.map(([id]) => id) })
|
||||
for (const relationship of relationships) {
|
||||
const requestedToUpdate = requested.find(([id]) => id === relationship.id)
|
||||
if (!requestedToUpdate)
|
||||
continue
|
||||
requestedToUpdate[1].value = relationship
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleFollowAccount(relationship: mastodon.v1.Relationship, account: mastodon.v1.Account) {
|
||||
const { client } = useMasto()
|
||||
const i18n = useNuxtApp().$i18n
|
||||
|
||||
const unfollow = relationship!.following || relationship!.requested
|
||||
|
||||
if (unfollow) {
|
||||
const confirmUnfollow = await openConfirmDialog({
|
||||
title: i18n.t('confirm.unfollow.title'),
|
||||
description: i18n.t('confirm.unfollow.description', [`@${account.acct}`]),
|
||||
confirm: i18n.t('confirm.unfollow.confirm'),
|
||||
cancel: i18n.t('confirm.unfollow.cancel'),
|
||||
})
|
||||
if (confirmUnfollow.choice !== 'confirm')
|
||||
return
|
||||
}
|
||||
|
||||
if (unfollow) {
|
||||
relationship!.following = false
|
||||
relationship!.requested = false
|
||||
}
|
||||
else if (account.locked) {
|
||||
relationship!.requested = true
|
||||
}
|
||||
else {
|
||||
relationship!.following = true
|
||||
}
|
||||
|
||||
relationship = await client.value.v1.accounts.$select(account.id)[unfollow ? 'unfollow' : 'follow']()
|
||||
}
|
||||
|
||||
export async function toggleMuteAccount(relationship: mastodon.v1.Relationship, account: mastodon.v1.Account) {
|
||||
const { client } = useMasto()
|
||||
const i18n = useNuxtApp().$i18n
|
||||
|
||||
let duration = 0 // default 0 == indefinite
|
||||
let notifications = true // default true = mute notifications
|
||||
if (!relationship!.muting) {
|
||||
const confirmMute = await openConfirmDialog({
|
||||
title: i18n.t('confirm.mute_account.title'),
|
||||
description: i18n.t('confirm.mute_account.description', [account.acct]),
|
||||
confirm: i18n.t('confirm.mute_account.confirm'),
|
||||
cancel: i18n.t('confirm.mute_account.cancel'),
|
||||
extraOptionType: 'mute',
|
||||
})
|
||||
if (confirmMute.choice !== 'confirm')
|
||||
return
|
||||
|
||||
duration = confirmMute.extraOptions!.mute.duration
|
||||
notifications = confirmMute.extraOptions!.mute.notifications
|
||||
}
|
||||
|
||||
relationship!.muting = !relationship!.muting
|
||||
relationship = relationship!.muting
|
||||
? await client.value.v1.accounts.$select(account.id).mute({
|
||||
duration,
|
||||
notifications,
|
||||
})
|
||||
: await client.value.v1.accounts.$select(account.id).unmute()
|
||||
}
|
||||
|
||||
export async function toggleBlockAccount(relationship: mastodon.v1.Relationship, account: mastodon.v1.Account) {
|
||||
const { client } = useMasto()
|
||||
const i18n = useNuxtApp().$i18n
|
||||
|
||||
if (!relationship!.blocking) {
|
||||
const confirmBlock = await openConfirmDialog({
|
||||
title: i18n.t('confirm.block_account.title'),
|
||||
description: i18n.t('confirm.block_account.description', [account.acct]),
|
||||
confirm: i18n.t('confirm.block_account.confirm'),
|
||||
cancel: i18n.t('confirm.block_account.cancel'),
|
||||
})
|
||||
if (confirmBlock.choice !== 'confirm')
|
||||
return
|
||||
}
|
||||
|
||||
relationship!.blocking = !relationship!.blocking
|
||||
relationship = await client.value.v1.accounts.$select(account.id)[relationship!.blocking ? 'block' : 'unblock']()
|
||||
}
|
||||
|
||||
export async function toggleBlockDomain(relationship: mastodon.v1.Relationship, account: mastodon.v1.Account) {
|
||||
const { client } = useMasto()
|
||||
const i18n = useNuxtApp().$i18n
|
||||
|
||||
if (!relationship!.domainBlocking) {
|
||||
const confirmDomainBlock = await openConfirmDialog({
|
||||
title: i18n.t('confirm.block_domain.title'),
|
||||
description: i18n.t('confirm.block_domain.description', [getServerName(account)]),
|
||||
confirm: i18n.t('confirm.block_domain.confirm'),
|
||||
cancel: i18n.t('confirm.block_domain.cancel'),
|
||||
})
|
||||
if (confirmDomainBlock.choice !== 'confirm')
|
||||
return
|
||||
}
|
||||
|
||||
relationship!.domainBlocking = !relationship!.domainBlocking
|
||||
await client.value.v1.domainBlocks[relationship!.domainBlocking ? 'create' : 'remove']({ domain: getServerName(account) })
|
||||
}
|
|
@ -1,192 +0,0 @@
|
|||
import type { DraftItem, DraftMap } from '#shared/types'
|
||||
import type { Mutable } from '#shared/types/utils'
|
||||
import type { mastodon } from 'masto'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { STORAGE_KEY_DRAFTS } from '~/constants'
|
||||
|
||||
export const currentUserDrafts = (import.meta.server || process.test)
|
||||
? computed<DraftMap>(() => ({}))
|
||||
: useUserLocalStorage<DraftMap>(STORAGE_KEY_DRAFTS, () => ({}))
|
||||
|
||||
export const builtinDraftKeys = [
|
||||
'dialog',
|
||||
'home',
|
||||
]
|
||||
|
||||
const ALL_VISIBILITY = ['public', 'unlisted', 'private', 'direct'] as const
|
||||
|
||||
function getDefaultVisibility(currentVisibility: mastodon.v1.StatusVisibility) {
|
||||
// The default privacy only should be taken into account if it makes
|
||||
// the post more private than the replying to post
|
||||
const preferredVisibility = currentUser.value?.account.source.privacy || 'public'
|
||||
return ALL_VISIBILITY.indexOf(currentVisibility)
|
||||
> ALL_VISIBILITY.indexOf(preferredVisibility)
|
||||
? currentVisibility
|
||||
: preferredVisibility
|
||||
}
|
||||
|
||||
export function getDefaultDraftItem(options: Partial<Mutable<mastodon.rest.v1.CreateStatusParams> & Omit<DraftItem, 'params'>> = {}): DraftItem {
|
||||
const {
|
||||
attachments = [],
|
||||
initialText = '',
|
||||
status,
|
||||
inReplyToId,
|
||||
visibility,
|
||||
sensitive,
|
||||
spoilerText,
|
||||
language,
|
||||
mentions,
|
||||
poll,
|
||||
} = options
|
||||
|
||||
return {
|
||||
attachments,
|
||||
initialText,
|
||||
params: {
|
||||
status: status || '',
|
||||
poll,
|
||||
inReplyToId,
|
||||
visibility: getDefaultVisibility(visibility || 'public'),
|
||||
sensitive: sensitive ?? false,
|
||||
spoilerText: spoilerText || '',
|
||||
language: language || '', // auto inferred from current language on posting
|
||||
},
|
||||
mentions,
|
||||
lastUpdated: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDraftFromStatus(status: mastodon.v1.Status): Promise<DraftItem> {
|
||||
const info = {
|
||||
status: await convertMastodonHTML(status.content),
|
||||
visibility: status.visibility,
|
||||
attachments: status.mediaAttachments,
|
||||
sensitive: status.sensitive,
|
||||
spoilerText: status.spoilerText,
|
||||
language: status.language,
|
||||
inReplyToId: status.inReplyToId,
|
||||
}
|
||||
|
||||
return getDefaultDraftItem((status.mediaAttachments !== undefined && status.mediaAttachments.length > 0)
|
||||
? { ...info, mediaIds: status.mediaAttachments.map(att => att.id) }
|
||||
: {
|
||||
...info,
|
||||
poll: status.poll
|
||||
? {
|
||||
expiresIn: Math.abs(new Date().getTime() - new Date(status.poll.expiresAt!).getTime()) / 1000,
|
||||
options: [...status.poll.options.map(({ title }) => title), ''],
|
||||
multiple: status.poll.multiple,
|
||||
hideTotals: status.poll.options[0].votesCount === null,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function getAccountsToMention(status: mastodon.v1.Status) {
|
||||
const userId = currentUser.value?.account.id
|
||||
const accountsToMention = new Set<string>()
|
||||
if (status.account.id !== userId)
|
||||
accountsToMention.add(status.account.acct)
|
||||
status.mentions
|
||||
.filter(mention => mention.id !== userId)
|
||||
.map(mention => mention.acct)
|
||||
.forEach(i => accountsToMention.add(i))
|
||||
return Array.from(accountsToMention)
|
||||
}
|
||||
|
||||
export function getReplyDraft(status: mastodon.v1.Status) {
|
||||
const accountsToMention = getAccountsToMention(status)
|
||||
return {
|
||||
key: `reply-${status.id}`,
|
||||
draft: () => {
|
||||
return getDefaultDraftItem({
|
||||
initialText: '',
|
||||
inReplyToId: status!.id,
|
||||
sensitive: status.sensitive,
|
||||
spoilerText: status.spoilerText,
|
||||
visibility: status.visibility,
|
||||
mentions: accountsToMention,
|
||||
language: status.language,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function isEmptyDraft(drafts: Array<DraftItem> | DraftItem | null | undefined) {
|
||||
if (!drafts)
|
||||
return true
|
||||
|
||||
const draftsArray: Array<DraftItem> = Array.isArray(drafts) ? drafts : [drafts]
|
||||
|
||||
if (draftsArray.length === 0)
|
||||
return true
|
||||
|
||||
const anyDraftHasContent = draftsArray.some((draft) => {
|
||||
const { params, attachments } = draft
|
||||
const status = params.status ?? ''
|
||||
const text = htmlToText(status).trim().replace(/^(@\S+\s?)+/, '').replaceAll(/```/g, '').trim()
|
||||
|
||||
return (text.length > 0)
|
||||
|| (attachments.length > 0)
|
||||
})
|
||||
|
||||
return !anyDraftHasContent
|
||||
}
|
||||
|
||||
export interface UseDraft {
|
||||
draftItems: Ref<Array<DraftItem>>
|
||||
isEmpty: ComputedRef<boolean> | Ref<boolean>
|
||||
}
|
||||
|
||||
export function useDraft(
|
||||
draftKey: string,
|
||||
initial: () => DraftItem = () => getDefaultDraftItem({}),
|
||||
): UseDraft {
|
||||
const draftItems = computed({
|
||||
get() {
|
||||
if (!currentUserDrafts.value[draftKey])
|
||||
currentUserDrafts.value[draftKey] = [initial()]
|
||||
const drafts = currentUserDrafts.value[draftKey]
|
||||
if (Array.isArray(drafts))
|
||||
return drafts
|
||||
return [drafts]
|
||||
},
|
||||
set(val) {
|
||||
currentUserDrafts.value[draftKey] = val
|
||||
},
|
||||
})
|
||||
|
||||
const isEmpty = computed(() => isEmptyDraft(draftItems.value))
|
||||
|
||||
onUnmounted(async () => {
|
||||
// Remove draft if it's empty
|
||||
if (isEmpty.value && draftKey) {
|
||||
await nextTick()
|
||||
delete currentUserDrafts.value[draftKey]
|
||||
}
|
||||
})
|
||||
|
||||
return { draftItems, isEmpty }
|
||||
}
|
||||
|
||||
export function mentionUser(account: mastodon.v1.Account) {
|
||||
openPublishDialog('dialog', getDefaultDraftItem({
|
||||
status: `@${account.acct} `,
|
||||
}))
|
||||
}
|
||||
|
||||
export function directMessageUser(account: mastodon.v1.Account) {
|
||||
openPublishDialog('dialog', getDefaultDraftItem({
|
||||
status: `@${account.acct} `,
|
||||
visibility: 'direct',
|
||||
}))
|
||||
}
|
||||
|
||||
export function clearEmptyDrafts() {
|
||||
for (const key in currentUserDrafts.value) {
|
||||
if (builtinDraftKeys.includes(key) && !isEmptyDraft(currentUserDrafts.value[key]))
|
||||
continue
|
||||
if (isEmptyDraft(currentUserDrafts.value[key]))
|
||||
delete currentUserDrafts.value[key]
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue