Compare commits
No commits in common. "main" and "v0.8.0" have entirely different histories.
557 changed files with 19763 additions and 43358 deletions
|
@ -11,6 +11,7 @@ dist
|
||||||
.netlify/
|
.netlify/
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|
||||||
|
public/shiki
|
||||||
public/emojis
|
public/emojis
|
||||||
|
|
||||||
*~
|
*~
|
||||||
|
|
|
@ -8,7 +8,7 @@ NUXT_CLOUDFLARE_ACCOUNT_ID=
|
||||||
NUXT_CLOUDFLARE_NAMESPACE_ID=
|
NUXT_CLOUDFLARE_NAMESPACE_ID=
|
||||||
NUXT_CLOUDFLARE_API_TOKEN=
|
NUXT_CLOUDFLARE_API_TOKEN=
|
||||||
|
|
||||||
# 'cloudflare' | 'vercel' | 'fs'
|
# 'cloudflare' | 'fs'
|
||||||
NUXT_STORAGE_DRIVER=
|
NUXT_STORAGE_DRIVER=
|
||||||
NUXT_STORAGE_FS_BASE=
|
NUXT_STORAGE_FS_BASE=
|
||||||
|
|
||||||
|
|
13
.eslintignore
Normal file
13
.eslintignore
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
*.css
|
||||||
|
*.png
|
||||||
|
*.ico
|
||||||
|
*.toml
|
||||||
|
*.patch
|
||||||
|
*.txt
|
||||||
|
Dockerfile
|
||||||
|
public/
|
||||||
|
https-dev-config/localhost.crt
|
||||||
|
https-dev-config/localhost.key
|
||||||
|
Dockerfile
|
||||||
|
elk-translation-status.json
|
||||||
|
docs/translation-status.json
|
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
|
|
14
.github/renovate.json5
vendored
14
.github/renovate.json5
vendored
|
@ -3,14 +3,7 @@
|
||||||
"extends": ["config:base", "schedule:weekly", "group:allNonMajor"],
|
"extends": ["config:base", "schedule:weekly", "group:allNonMajor"],
|
||||||
"labels": ["c: dependencies"],
|
"labels": ["c: dependencies"],
|
||||||
"rangeStrategy": "bump",
|
"rangeStrategy": "bump",
|
||||||
"ignoreDeps": [
|
"node": false,
|
||||||
"vue",
|
|
||||||
"vue-tsc",
|
|
||||||
"typescript",
|
|
||||||
|
|
||||||
// Intl.Segmenter is not supported in Firefox
|
|
||||||
"string-length"
|
|
||||||
],
|
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
"groupName": "devDependencies",
|
"groupName": "devDependencies",
|
||||||
|
@ -40,6 +33,7 @@
|
||||||
"groupName": "lint",
|
"groupName": "lint",
|
||||||
"matchPackageNames": [
|
"matchPackageNames": [
|
||||||
"@antfu/eslint-config",
|
"@antfu/eslint-config",
|
||||||
|
"@types/prettier",
|
||||||
"eslint",
|
"eslint",
|
||||||
"prettier"
|
"prettier"
|
||||||
]
|
]
|
||||||
|
@ -63,10 +57,6 @@
|
||||||
{
|
{
|
||||||
"groupName": "typescript",
|
"groupName": "typescript",
|
||||||
"matchPackageNames": ["typescript"]
|
"matchPackageNames": ["typescript"]
|
||||||
},
|
|
||||||
{
|
|
||||||
"matchDatasources": ["node-version"],
|
|
||||||
"enabled": false
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"vulnerabilityAlerts": {
|
"vulnerabilityAlerts": {
|
||||||
|
|
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
|
@ -10,21 +10,18 @@ on:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
workflow_dispatch: {}
|
workflow_dispatch: {}
|
||||||
merge_group: {}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
# workaround for npm registry key change
|
- run: corepack enable
|
||||||
# ref. `pnpm@10.1.0` / `pnpm@9.15.4` cannot be installed due to key id mismatch · Issue #612 · nodejs/corepack
|
- uses: actions/setup-node@v3
|
||||||
# - https://github.com/nodejs/corepack/issues/612#issuecomment-2629496091
|
|
||||||
- run: npm i -g corepack@latest && corepack enable
|
|
||||||
- uses: actions/setup-node@v4.4.0
|
|
||||||
with:
|
with:
|
||||||
node-version-file: .nvmrc
|
node-version: 18
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
- name: 📦 Install dependencies
|
- name: 📦 Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
@ -33,8 +30,7 @@ jobs:
|
||||||
run: pnpm nuxi prepare
|
run: pnpm nuxi prepare
|
||||||
|
|
||||||
- name: 🧪 Test project
|
- name: 🧪 Test project
|
||||||
run: pnpm test:ci
|
run: pnpm test tests/unit
|
||||||
timeout-minutes: 10
|
|
||||||
|
|
||||||
- name: 📝 Lint
|
- name: 📝 Lint
|
||||||
run: pnpm lint
|
run: pnpm lint
|
||||||
|
|
20
.github/workflows/docker.yml
vendored
20
.github/workflows/docker.yml
vendored
|
@ -16,29 +16,29 @@ jobs:
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v3
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: metal
|
id: metal
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v4
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
ghcr.io/${{ github.repository }}
|
ghcr.io/elk-zone/elk
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v2
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v2
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ github.token }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.metal.outputs.tags }}
|
tags: ${{ steps.metal.outputs.tags }}
|
||||||
labels: ${{ steps.metal.outputs.labels }}
|
labels: ${{ steps.metal.outputs.labels }}
|
||||||
|
|
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
@ -12,14 +12,14 @@ jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set node
|
- name: Set node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version-file: .nvmrc
|
node-version: 18
|
||||||
|
|
||||||
- run: npx changelogithub
|
- run: npx changelogithub
|
||||||
env:
|
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
|
name: Semantic Pull Request
|
||||||
steps:
|
steps:
|
||||||
- name: Validate PR title
|
- name: Validate PR title
|
||||||
uses: amannn/action-semantic-pull-request@v5.5.3
|
uses: amannn/action-semantic-pull-request@v5.2.0
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -2,7 +2,6 @@ node_modules
|
||||||
*.log
|
*.log
|
||||||
dist
|
dist
|
||||||
.output
|
.output
|
||||||
.pnpm-store
|
|
||||||
.nuxt
|
.nuxt
|
||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
@ -12,6 +11,7 @@ dist
|
||||||
.eslintcache
|
.eslintcache
|
||||||
elk-translation-status.json
|
elk-translation-status.json
|
||||||
|
|
||||||
|
public/shiki
|
||||||
public/emojis
|
public/emojis
|
||||||
|
|
||||||
*~
|
*~
|
||||||
|
|
2
.npmrc
2
.npmrc
|
@ -1,4 +1,4 @@
|
||||||
shamefully-hoist=true
|
shamefully-hoist=true
|
||||||
|
strict-peer-dependencies=false
|
||||||
shell-emulator=true
|
shell-emulator=true
|
||||||
ignore-workspace-root-check=true
|
ignore-workspace-root-check=true
|
||||||
package-manager-strict=false
|
|
||||||
|
|
2
.nvmrc
2
.nvmrc
|
@ -1 +1 @@
|
||||||
22
|
v18
|
45
.vscode/settings.json
vendored
45
.vscode/settings.json
vendored
|
@ -5,6 +5,10 @@
|
||||||
"unmute",
|
"unmute",
|
||||||
"unstorage"
|
"unstorage"
|
||||||
],
|
],
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": true
|
||||||
|
},
|
||||||
|
"editor.formatOnSave": false,
|
||||||
"files.associations": {
|
"files.associations": {
|
||||||
"*.css": "postcss"
|
"*.css": "postcss"
|
||||||
},
|
},
|
||||||
|
@ -19,44 +23,7 @@
|
||||||
"i18n-ally.preferredDelimiter": "_",
|
"i18n-ally.preferredDelimiter": "_",
|
||||||
"i18n-ally.sortKeys": true,
|
"i18n-ally.sortKeys": true,
|
||||||
"i18n-ally.sourceLanguage": "en",
|
"i18n-ally.sourceLanguage": "en",
|
||||||
|
|
||||||
// Enable the ESlint flat config support
|
|
||||||
"eslint.experimental.useFlatConfig": true,
|
|
||||||
|
|
||||||
// Disable the default formatter, use eslint instead
|
|
||||||
"prettier.enable": false,
|
"prettier.enable": false,
|
||||||
"editor.formatOnSave": false,
|
"volar.completion.preferredTagNameCase": "pascal",
|
||||||
|
"volar.completion.preferredAttrNameCase": "kebab"
|
||||||
// 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"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,16 +6,23 @@ Refer also to https://github.com/antfu/contribute.
|
||||||
|
|
||||||
For guidelines on contributing to the documentation, refer to the [docs README](./docs/README.md).
|
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
|
### Local Setup
|
||||||
|
|
||||||
To develop and test the Elk package:
|
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.
|
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.
|
2. Ensure using the latest Node.js (16.x).
|
||||||
If you have [nvm](https://github.com/nvm-sh/nvm), you can run `nvm i` to install the required version.
|
If you have [nvm](https://github.com/nvm-sh/nvm), you can run `nvm i` to install the required version.
|
||||||
|
|
||||||
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:
|
4. Check out a branch where you can work and commit your changes:
|
||||||
```shell
|
```shell
|
||||||
|
@ -77,26 +84,26 @@ Simple approach used by most websites of relying on direction set in HTML elemen
|
||||||
We've added some `UnoCSS` utilities styles to help you with that:
|
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`. The same rules apply to margin.
|
||||||
- Do not use `rtl-` classes, such as `rtl-left-0`.
|
- 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 the rule above. For icons inside the 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`.
|
- 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 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 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`.
|
||||||
|
|
||||||
## Internationalization
|
## Internationalization
|
||||||
|
|
||||||
We are using [vue-i18n](https://vue-i18n.intlify.dev/) via [nuxt-i18n](https://i18n.nuxtjs.org/) to handle internationalization.
|
We are using [vue-i18n](https://vue-i18n.intlify.dev/) via [nuxt-i18n](https://v8.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.
|
You can check the current [translation status](https://docs.elk.zone/docs/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:
|
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`
|
- from root folder: `nr prepare-translation-status`
|
||||||
- change to `docs` folder and run docs dev server `nr dev`
|
- change to `docs` folder and run docs dev server `nr dev`
|
||||||
- open `http://localhost:3000/guide/contributing#translation-status` in your browser
|
- open `http://localhost:3000/docs/guide/contributing#translation-status` in your browser
|
||||||
|
|
||||||
### Adding a new language
|
### Adding a new language
|
||||||
|
|
||||||
1. Add a new file in [locales](./locales) folder with the language code as the filename.
|
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`:
|
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 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)
|
||||||
- Add all country variants in [country variants object](./config/i18n.ts#L12)
|
- Add all country variants in [country variants object](./config/i18n.ts#L12)
|
||||||
|
|
|
@ -6,10 +6,7 @@ WORKDIR /elk
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
|
|
||||||
# Prepare pnpm https://pnpm.io/installation#using-corepack
|
# Prepare pnpm https://pnpm.io/installation#using-corepack
|
||||||
# workaround for npm registry key change
|
RUN corepack enable
|
||||||
# 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
|
|
||||||
|
|
||||||
# Prepare deps
|
# Prepare deps
|
||||||
RUN apk update
|
RUN apk update
|
||||||
|
@ -17,7 +14,6 @@ RUN apk add git --no-cache
|
||||||
|
|
||||||
# Prepare build deps ( ignore postinstall scripts for now )
|
# Prepare build deps ( ignore postinstall scripts for now )
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
COPY .npmrc ./
|
|
||||||
COPY pnpm-lock.yaml ./
|
COPY pnpm-lock.yaml ./
|
||||||
COPY patches ./patches
|
COPY patches ./patches
|
||||||
RUN pnpm i --frozen-lockfile --ignore-scripts
|
RUN pnpm i --frozen-lockfile --ignore-scripts
|
||||||
|
|
91
README.md
91
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.
|
<p align="center">
|
||||||
|
<a href="https://elk.zone/" target="_blank" rel="noopener noreferrer" >
|
||||||
~ Ayo Ayco
|
<img src="./public/elk-og.png" alt="Elk screenshots" width="600" height="auto">
|
||||||
|
</a>
|
||||||
---
|
</p>
|
||||||
|
|
||||||
## ⚠️ Elk is in Alpha
|
## ⚠️ Elk is in Alpha
|
||||||
|
|
||||||
|
@ -27,24 +39,73 @@ The Elk team maintains a deployment at:
|
||||||
|
|
||||||
### Self-Host Docker Deployment
|
### Self-Host Docker Deployment
|
||||||
|
|
||||||
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.
|
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 of 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.
|
One could put Elk behind popular reverse proxies with SSL Handling like Traefik, NGINX etc.
|
||||||
|
|
||||||
1. checkout source ```git clone https://github.com/elk-zone/elk.git```
|
1. checkout source ```git clone https://github.com/elk-zone/elk.git```
|
||||||
1. got into new source dir: ```cd elk```
|
1. got into new source dir: ```cd elk```
|
||||||
|
1. build Docker image: ```docker build .```
|
||||||
1. create local storage directory for settings: ```mkdir elk-storage```
|
1. create local storage directory for settings: ```mkdir elk-storage```
|
||||||
1. adjust permissions of storage dir: ```sudo chown 911:911 ./elk-storage```
|
1. adjust permissions of storage dir: ```sudo chown 911:911 ./elk-storage```
|
||||||
1. start container: ```docker-compose up --build -d```
|
1. start container: ```docker-compose up -d```
|
||||||
|
|
||||||
> [!NOTE]
|
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.
|
||||||
> 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.
|
|
||||||
|
|
||||||
|
### Ecosystem
|
||||||
|
|
||||||
|
These are known deployments using Elk as an alternative Web client for Mastodon servers or as a base for other projects in the fediverse:
|
||||||
|
|
||||||
|
- [elk.fedified.com](https://elk.fedified.com) - Use Elk to log into any compatible instance
|
||||||
|
- [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.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.
|
> **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
|
## 🧑💻 Contributing
|
||||||
|
|
||||||
We're really excited that you're interested in contributing to Elk! Before submitting your contribution, please read through the following guide.
|
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
|
### Local Setup
|
||||||
|
|
||||||
Clone the repository and run on the root folder:
|
Clone the repository and run on the root folder:
|
||||||
|
@ -73,7 +134,7 @@ nr test
|
||||||
|
|
||||||
## 📲 PWA
|
## 📲 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.
|
You can consult the [PWA documentation](https://docs.elk.zone/docs/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
|
## 🦄 Stack
|
||||||
|
|
||||||
|
@ -86,7 +147,7 @@ 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
|
- [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
|
- [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
|
- [Masto.js](https://neet.github.io/masto.js) - Mastodon API client in TypeScript
|
||||||
- [shiki](https://shiki.style/) - A beautiful yet powerful syntax highlighter
|
- [shiki](https://shiki.matsu.io/) - A beautiful Syntax Highlighter
|
||||||
- [vite-plugin-pwa](https://github.com/vite-pwa/vite-plugin-pwa) - Prompt for update, Web Push Notifications and Web Share Target API
|
- [vite-plugin-pwa](https://github.com/vite-pwa/vite-plugin-pwa) - Prompt for update, Web Push Notifications and Web Share Target API
|
||||||
|
|
||||||
## 👨💻 Contributors
|
## 👨💻 Contributors
|
||||||
|
|
|
@ -4,12 +4,10 @@ provideGlobalCommands()
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
if (import.meta.server && !route.path.startsWith('/settings')) {
|
if (process.server && !route.path.startsWith('/settings')) {
|
||||||
const url = useRequestURL()
|
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
meta: [
|
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,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,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,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,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,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,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,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,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,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]
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,198 +0,0 @@
|
||||||
import type { mastodon } from 'masto'
|
|
||||||
|
|
||||||
export interface TranslationResponse {
|
|
||||||
translatedText: string
|
|
||||||
detectedLanguage: {
|
|
||||||
confidence: number
|
|
||||||
language: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// @see https://github.com/LibreTranslate/LibreTranslate/tree/main/libretranslate/locales
|
|
||||||
export const supportedTranslationCodes = [
|
|
||||||
'ar',
|
|
||||||
'az',
|
|
||||||
'cs',
|
|
||||||
'da',
|
|
||||||
'de',
|
|
||||||
'el',
|
|
||||||
'en',
|
|
||||||
'eo',
|
|
||||||
'es',
|
|
||||||
'fa',
|
|
||||||
'fi',
|
|
||||||
'fr',
|
|
||||||
'ga',
|
|
||||||
'he',
|
|
||||||
'hi',
|
|
||||||
'hu',
|
|
||||||
'id',
|
|
||||||
'it',
|
|
||||||
'ja',
|
|
||||||
'ko',
|
|
||||||
'nl',
|
|
||||||
'pl',
|
|
||||||
'pt',
|
|
||||||
'ru',
|
|
||||||
'sk',
|
|
||||||
'sv',
|
|
||||||
'tr',
|
|
||||||
'uk',
|
|
||||||
'vi',
|
|
||||||
'zh',
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const translationAPISupported = 'Translator' in globalThis && 'LanguageDetector' in globalThis
|
|
||||||
|
|
||||||
const anchorMarkupRegEx = /<a[^>]*>.*?<\/a>/g
|
|
||||||
|
|
||||||
export function getLanguageCode() {
|
|
||||||
let code = 'en'
|
|
||||||
const getCode = (code: string) => code.replace(/-.*$/, '')
|
|
||||||
if (import.meta.client) {
|
|
||||||
const { locale } = useI18n()
|
|
||||||
code = getCode(locale.value ? locale.value : navigator.language)
|
|
||||||
}
|
|
||||||
return code
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TranslationErr {
|
|
||||||
data?: {
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceTranslatedLinksWithOriginal(text: string) {
|
|
||||||
return text.replace(anchorMarkupRegEx, (match) => {
|
|
||||||
const tagLink = anchorMarkupRegEx.exec(text)
|
|
||||||
return tagLink ? tagLink[0] : match
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function translateText(text: string, from: string | null | undefined, to: string) {
|
|
||||||
const config = useRuntimeConfig()
|
|
||||||
const status = ref({
|
|
||||||
success: false,
|
|
||||||
error: '',
|
|
||||||
text: '',
|
|
||||||
})
|
|
||||||
try {
|
|
||||||
const response = await ($fetch as any)(config.public.translateApi, {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
q: text,
|
|
||||||
source: from ?? 'auto',
|
|
||||||
target: to,
|
|
||||||
format: 'html',
|
|
||||||
api_key: '',
|
|
||||||
},
|
|
||||||
}) as TranslationResponse
|
|
||||||
status.value.success = true
|
|
||||||
status.value.text = replaceTranslatedLinksWithOriginal(response.translatedText)
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
// TODO: improve type
|
|
||||||
if ((err as TranslationErr).data?.error)
|
|
||||||
status.value.error = (err as TranslationErr).data!.error!
|
|
||||||
else
|
|
||||||
status.value.error = 'Unknown Error, Please check your console in browser devtool.'
|
|
||||||
console.error('Translate Post Error: ', err)
|
|
||||||
}
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
|
|
||||||
const translations = new WeakMap<mastodon.v1.Status | mastodon.v1.StatusEdit, {
|
|
||||||
visible: boolean
|
|
||||||
text: string
|
|
||||||
success: boolean
|
|
||||||
error: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
export async function useTranslation(status: mastodon.v1.Status | mastodon.v1.StatusEdit, to: string) {
|
|
||||||
if (!translations.has(status))
|
|
||||||
translations.set(status, reactive({ visible: false, text: '', success: false, error: '' }))
|
|
||||||
|
|
||||||
const translation = translations.get(status)!
|
|
||||||
const userSettings = useUserSettings()
|
|
||||||
|
|
||||||
let shouldTranslate = false
|
|
||||||
if ('language' in status) {
|
|
||||||
shouldTranslate = typeof status.language === 'string' && status.language !== to && !userSettings.value.disabledTranslationLanguages.includes(status.language)
|
|
||||||
if (!translationAPISupported) {
|
|
||||||
shouldTranslate = shouldTranslate && supportedTranslationCodes.includes(to as any)
|
|
||||||
&& supportedTranslationCodes.includes(status.language as any)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
shouldTranslate = shouldTranslate && (await (globalThis as any).Translator.availability({
|
|
||||||
sourceLanguage: status.language,
|
|
||||||
targetLanguage: to,
|
|
||||||
})) !== 'unavailable'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const enabled = /*! !useRuntimeConfig().public.translateApi && */ shouldTranslate
|
|
||||||
|
|
||||||
async function toggle() {
|
|
||||||
if (!shouldTranslate)
|
|
||||||
return
|
|
||||||
|
|
||||||
if (!translation.text) {
|
|
||||||
let translated = {
|
|
||||||
value: {
|
|
||||||
error: '',
|
|
||||||
text: '',
|
|
||||||
success: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if (translationAPISupported && 'language' in status) {
|
|
||||||
let sourceLanguage = status.language
|
|
||||||
if (!sourceLanguage) {
|
|
||||||
const languageDetector = await (globalThis as any).LanguageDetector.create()
|
|
||||||
// Make sure HTML markup doesn't derail language detection.
|
|
||||||
const div = document.createElement('div')
|
|
||||||
div.innerHTML = status.content
|
|
||||||
// eslint-disable-next-line unicorn/prefer-dom-node-text-content
|
|
||||||
const detectedLanguages = await languageDetector.detect(div.innerText)
|
|
||||||
sourceLanguage = detectedLanguages[0].detectedLanguage
|
|
||||||
if (sourceLanguage === 'und') {
|
|
||||||
throw new Error('Could not detect source language.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const translator = await (globalThis as any).Translator.create({
|
|
||||||
sourceLanguage,
|
|
||||||
targetLanguage: to,
|
|
||||||
})
|
|
||||||
try {
|
|
||||||
let text = await translator.translate(status.content)
|
|
||||||
text = replaceTranslatedLinksWithOriginal(text)
|
|
||||||
translated.value = {
|
|
||||||
error: '',
|
|
||||||
text,
|
|
||||||
success: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
translated.value = {
|
|
||||||
error: (error as Error).message,
|
|
||||||
text: '',
|
|
||||||
success: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
if ('language' in status) {
|
|
||||||
translated = await translateText(status.content, status.language, to)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
translation.error = translated.value.error
|
|
||||||
translation.text = translated.value.text
|
|
||||||
translation.success = translated.value.success
|
|
||||||
}
|
|
||||||
translation.visible = !translation.visible
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
enabled,
|
|
||||||
toggle,
|
|
||||||
translation,
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
import type { mastodon } from 'masto'
|
|
||||||
import { NOTIFICATION_FILTER_TYPES } from '~/constants'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Typeguard to check if an object is a valid notification filter
|
|
||||||
* @param obj the object to be checked
|
|
||||||
* @returns boolean and assigns type to object if true
|
|
||||||
*/
|
|
||||||
export function isNotificationFilter(obj: unknown): obj is mastodon.v1.NotificationType {
|
|
||||||
return !!obj && NOTIFICATION_FILTER_TYPES.includes(obj as unknown as mastodon.v1.NotificationType)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Typeguard to check if an object is a valid notification
|
|
||||||
* @param obj the object to be checked
|
|
||||||
* @returns boolean and assigns type to object if true
|
|
||||||
*/
|
|
||||||
export function isNotification(obj: unknown): obj is mastodon.v1.NotificationType {
|
|
||||||
return !!obj && ['mention', ...NOTIFICATION_FILTER_TYPES].includes(obj as unknown as mastodon.v1.NotificationType)
|
|
||||||
}
|
|
|
@ -1,28 +0,0 @@
|
||||||
import type { Node } from 'ultrahtml'
|
|
||||||
import { decode } from 'tiny-decode'
|
|
||||||
import { parse, TEXT_NODE } from 'ultrahtml'
|
|
||||||
|
|
||||||
export const maxAccountFieldCount = computed(() => isGlitchEdition.value ? 16 : 4)
|
|
||||||
|
|
||||||
export function convertMetadata(metadata: string) {
|
|
||||||
try {
|
|
||||||
const tree = parse(metadata)
|
|
||||||
return (tree.children as Node[]).map(n => convertToText(n)).join('').trim()
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function convertToText(input: Node): string {
|
|
||||||
let text = ''
|
|
||||||
|
|
||||||
if (input.type === TEXT_NODE)
|
|
||||||
return decode(input.value)
|
|
||||||
|
|
||||||
if ('children' in input)
|
|
||||||
text = (input.children as Node[]).map(n => convertToText(n)).join('')
|
|
||||||
|
|
||||||
return text
|
|
||||||
}
|
|
|
@ -1,99 +0,0 @@
|
||||||
import type { DraftItem } from '#shared/types'
|
|
||||||
import type { mastodon } from 'masto'
|
|
||||||
|
|
||||||
const maxThreadLength = 99
|
|
||||||
|
|
||||||
export function useThreadComposer(draftKey: string, initial?: () => DraftItem) {
|
|
||||||
const { draftItems } = useDraft(draftKey, initial)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the thread is active (has more than one item)
|
|
||||||
*/
|
|
||||||
const threadIsActive = computed<boolean>(() => draftItems.value.length > 1)
|
|
||||||
|
|
||||||
const threadIsSending = ref(false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add an item to the thread
|
|
||||||
*/
|
|
||||||
function addThreadItem() {
|
|
||||||
if (draftItems.value.length >= maxThreadLength) {
|
|
||||||
// TODO handle with error message that tells the user what's wrong
|
|
||||||
// For now just fail silently without breaking anything
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastItem = draftItems.value[draftItems.value.length - 1]
|
|
||||||
draftItems.value.push(getDefaultDraftItem({
|
|
||||||
language: lastItem.params.language,
|
|
||||||
sensitive: lastItem.params.sensitive,
|
|
||||||
spoilerText: lastItem.params.spoilerText,
|
|
||||||
visibility: lastItem.params.visibility,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param index index of the draft to remove from the thread
|
|
||||||
*/
|
|
||||||
function removeThreadItem(index: number) {
|
|
||||||
draftItems.value.splice(index, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish all items in the thread in order
|
|
||||||
*/
|
|
||||||
async function publishThread() {
|
|
||||||
const allFailedMessages: Array<string> = []
|
|
||||||
const isAReplyThread = Boolean(draftItems.value[0].params.inReplyToId)
|
|
||||||
threadIsSending.value = true
|
|
||||||
|
|
||||||
let lastPublishedStatus: mastodon.v1.Status | null = null
|
|
||||||
let amountPublished = 0
|
|
||||||
for (const draftItem of draftItems.value) {
|
|
||||||
if (lastPublishedStatus)
|
|
||||||
draftItem.params.inReplyToId = lastPublishedStatus.id
|
|
||||||
|
|
||||||
const { publishDraft, failedMessages } = usePublish({
|
|
||||||
draftItem: ref(draftItem),
|
|
||||||
expanded: computed(() => true),
|
|
||||||
isUploading: ref(false),
|
|
||||||
initialDraft: () => draftItem,
|
|
||||||
isPartOfThread: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const status = await publishDraft()
|
|
||||||
if (status) {
|
|
||||||
lastPublishedStatus = status
|
|
||||||
amountPublished++
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
allFailedMessages.push(...failedMessages.value)
|
|
||||||
// Stop publishing if one fails
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Remove all published items from the thread
|
|
||||||
draftItems.value.splice(0, amountPublished)
|
|
||||||
threadIsSending.value = false
|
|
||||||
|
|
||||||
// If we have errors, return them
|
|
||||||
if (allFailedMessages.length > 0)
|
|
||||||
return allFailedMessages
|
|
||||||
|
|
||||||
// If the thread was a reply and all items were published, jump to it
|
|
||||||
if (isAReplyThread && lastPublishedStatus && draftItems.value.length === 0)
|
|
||||||
navigateToStatus({ status: lastPublishedStatus })
|
|
||||||
|
|
||||||
return lastPublishedStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
threadItems: draftItems,
|
|
||||||
threadIsActive,
|
|
||||||
addThreadItem,
|
|
||||||
removeThreadItem,
|
|
||||||
publishThread,
|
|
||||||
threadIsSending,
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,120 +0,0 @@
|
||||||
import type { ExtendedRegExpMatchArray, InputRuleFinder, nodeInputRule } from '@tiptap/core'
|
|
||||||
import type { NodeType } from '@tiptap/pm/model'
|
|
||||||
import {
|
|
||||||
callOrReturn,
|
|
||||||
InputRule,
|
|
||||||
mergeAttributes,
|
|
||||||
Node,
|
|
||||||
nodePasteRule,
|
|
||||||
} from '@tiptap/core'
|
|
||||||
import { emojiRegEx, getEmojiAttributes } from '~~/config/emojis'
|
|
||||||
|
|
||||||
function wrapHandler<T extends (...args: any[]) => any>(handler: T): T {
|
|
||||||
return <T>((...args: any[]) => {
|
|
||||||
try {
|
|
||||||
return handler(...args)
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function createEmojiRule<NR extends typeof nodeInputRule | typeof nodePasteRule>(
|
|
||||||
nodeRule: NR,
|
|
||||||
type: Parameters<NR>[0]['type'],
|
|
||||||
): ReturnType<NR>[] {
|
|
||||||
const rule = nodeRule({
|
|
||||||
find: emojiRegEx as RegExp,
|
|
||||||
type,
|
|
||||||
getAttributes: (match: ExtendedRegExpMatchArray) => {
|
|
||||||
const [native] = match
|
|
||||||
return getEmojiAttributes(native)
|
|
||||||
},
|
|
||||||
}) as ReturnType<NR>
|
|
||||||
|
|
||||||
// Error catch for unsupported emoji
|
|
||||||
rule.handler = wrapHandler(rule.handler.bind(rule))
|
|
||||||
|
|
||||||
return [rule]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TiptapPluginEmoji = Node.create({
|
|
||||||
name: 'em-emoji',
|
|
||||||
|
|
||||||
inline: () => true,
|
|
||||||
group: () => 'inline',
|
|
||||||
draggable: false,
|
|
||||||
|
|
||||||
parseHTML() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
tag: 'img.iconify-emoji',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
addAttributes() {
|
|
||||||
return {
|
|
||||||
alt: {
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
src: {
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
class: {
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
renderHTML(args) {
|
|
||||||
return ['img', mergeAttributes(this.options.HTMLAttributes, args.HTMLAttributes)]
|
|
||||||
},
|
|
||||||
|
|
||||||
addCommands() {
|
|
||||||
return {
|
|
||||||
insertEmoji: code => ({ commands }) => {
|
|
||||||
return commands.insertContent({
|
|
||||||
type: this.name,
|
|
||||||
attrs: getEmojiAttributes(code),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
addInputRules() {
|
|
||||||
function emojiInputRule(config: {
|
|
||||||
find: InputRuleFinder
|
|
||||||
type: NodeType
|
|
||||||
getAttributes?:
|
|
||||||
| Record<string, any>
|
|
||||||
| ((match: ExtendedRegExpMatchArray) => Record<string, any>)
|
|
||||||
| false
|
|
||||||
| null
|
|
||||||
}) {
|
|
||||||
return new InputRule({
|
|
||||||
find: config.find,
|
|
||||||
handler: ({ state, range, match }) => {
|
|
||||||
const attributes = callOrReturn(config.getAttributes, undefined, match) || {}
|
|
||||||
const { tr } = state
|
|
||||||
const start = range.from
|
|
||||||
const end = range.to
|
|
||||||
|
|
||||||
tr.insert(start, config.type.create(attributes)).delete(
|
|
||||||
tr.mapping.map(start),
|
|
||||||
tr.mapping.map(end),
|
|
||||||
)
|
|
||||||
|
|
||||||
tr.scrollIntoView()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return createEmojiRule(emojiInputRule, this.type)
|
|
||||||
},
|
|
||||||
|
|
||||||
addPasteRules() {
|
|
||||||
return createEmojiRule(nodePasteRule, this.type)
|
|
||||||
},
|
|
||||||
})
|
|
|
@ -1,23 +0,0 @@
|
||||||
import type { Parser } from 'prosemirror-highlight/shiki'
|
|
||||||
import type { BuiltinLanguage } from 'shiki'
|
|
||||||
import { createParser } from 'prosemirror-highlight/shiki'
|
|
||||||
|
|
||||||
let parser: Parser | undefined
|
|
||||||
|
|
||||||
export const shikiParser: Parser = (options) => {
|
|
||||||
const lang = options.language ?? 'text'
|
|
||||||
|
|
||||||
// Register the language if it's not yet registered
|
|
||||||
const { highlighter, promise } = useHighlighter(lang as BuiltinLanguage)
|
|
||||||
|
|
||||||
// If the highlighter or the language is not available, return a promise that
|
|
||||||
// will resolve when it's ready. When the promise resolves, the editor will
|
|
||||||
// re-parse the code block.
|
|
||||||
if (!highlighter)
|
|
||||||
return promise ?? []
|
|
||||||
|
|
||||||
if (!parser)
|
|
||||||
parser = createParser(highlighter)
|
|
||||||
|
|
||||||
return parser(options)
|
|
||||||
}
|
|
|
@ -1,25 +0,0 @@
|
||||||
import CodeBlock from '@tiptap/extension-code-block'
|
|
||||||
import { VueNodeViewRenderer } from '@tiptap/vue-3'
|
|
||||||
|
|
||||||
import { createHighlightPlugin } from 'prosemirror-highlight'
|
|
||||||
import TiptapCodeBlock from '~/components/tiptap/TiptapCodeBlock.vue'
|
|
||||||
import { shikiParser } from './shiki-parser'
|
|
||||||
|
|
||||||
export const TiptapPluginCodeBlockShiki = CodeBlock.extend({
|
|
||||||
addOptions() {
|
|
||||||
return {
|
|
||||||
...this.parent?.(),
|
|
||||||
defaultLanguage: null,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
|
||||||
return [
|
|
||||||
createHighlightPlugin({ parser: shikiParser, nodeTypes: ['codeBlock'] }),
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
addNodeView() {
|
|
||||||
return VueNodeViewRenderer(TiptapCodeBlock)
|
|
||||||
},
|
|
||||||
})
|
|
|
@ -1,44 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { mastodon } from 'masto'
|
|
||||||
|
|
||||||
const params = useRoute().params
|
|
||||||
const handle = computed(() => params.account as string)
|
|
||||||
|
|
||||||
definePageMeta({ name: 'account-index' })
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
const account = await fetchAccountByHandle(handle.value)
|
|
||||||
|
|
||||||
// we need to ensure `pinned === true` on status
|
|
||||||
// because this prop is appeared only on current account's posts
|
|
||||||
function applyPinned(statuses: mastodon.v1.Status[]) {
|
|
||||||
return statuses.map((status) => {
|
|
||||||
status.pinned = true
|
|
||||||
return status
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function reorderAndFilter(items: mastodon.v1.Status[]) {
|
|
||||||
return reorderedTimeline(items, 'account')
|
|
||||||
}
|
|
||||||
|
|
||||||
const pinnedPaginator = useMastoClient().v1.accounts.$select(account.id).statuses.list({ pinned: true })
|
|
||||||
const postPaginator = useMastoClient().v1.accounts.$select(account.id).statuses.list({ limit: 30, excludeReplies: true })
|
|
||||||
|
|
||||||
if (account) {
|
|
||||||
useHydratedHead({
|
|
||||||
title: () => `${t('nav.profile')} | ${getDisplayName(account)} (@${account.acct})`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<AccountTabs />
|
|
||||||
<TimelinePaginator :paginator="pinnedPaginator" :preprocess="applyPinned" context="account" :account="account" :end-message="false" />
|
|
||||||
<!-- Upper border -->
|
|
||||||
<div h="1px" w-auto bg-border mb-1 />
|
|
||||||
<TimelinePaginator :paginator="postPaginator" :preprocess="reorderAndFilter" context="account" :account="account" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
|
@ -1,29 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { STORAGE_KEY_HIDE_EXPLORE_POSTS_TIPS, STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE } from '~/constants'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
const paginator = useMastoClient().v1.trends.statuses.list()
|
|
||||||
|
|
||||||
const hideNewsTips = useLocalStorage(STORAGE_KEY_HIDE_EXPLORE_POSTS_TIPS, false)
|
|
||||||
|
|
||||||
useHydratedHead({
|
|
||||||
title: () => `${t('tab.posts')} | ${t('nav.explore')}`,
|
|
||||||
})
|
|
||||||
|
|
||||||
const lastAccessedExploreRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE, '')
|
|
||||||
lastAccessedExploreRoute.value = route.path.replace(/(.*\/explore\/?)/, '')
|
|
||||||
|
|
||||||
onActivated(() => {
|
|
||||||
lastAccessedExploreRoute.value = route.path.replace(/(.*\/explore\/?)/, '')
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<CommonAlert v-if="isHydrated && !hideNewsTips" @close="hideNewsTips = true">
|
|
||||||
<p>{{ $t('tooltip.explore_posts_intro') }}</p>
|
|
||||||
</CommonAlert>
|
|
||||||
<!-- TODO: Tabs for trending statuses, tags, and links -->
|
|
||||||
<TimelinePaginator v-if="isHydrated" :paginator="paginator" context="public" />
|
|
||||||
</template>
|
|
|
@ -1,32 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { STORAGE_KEY_HIDE_EXPLORE_TAGS_TIPS, STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE } from '~/constants'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const route = useRoute()
|
|
||||||
const { client } = useMasto()
|
|
||||||
|
|
||||||
const paginator = client.value.v1.trends.tags.list({
|
|
||||||
limit: 20,
|
|
||||||
})
|
|
||||||
|
|
||||||
const hideTagsTips = useLocalStorage(STORAGE_KEY_HIDE_EXPLORE_TAGS_TIPS, false)
|
|
||||||
|
|
||||||
useHydratedHead({
|
|
||||||
title: () => `${t('tab.hashtags')} | ${t('nav.explore')}`,
|
|
||||||
})
|
|
||||||
|
|
||||||
const lastAccessedExploreRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE, '')
|
|
||||||
lastAccessedExploreRoute.value = route.path.replace(/(.*\/explore\/?)/, '')
|
|
||||||
|
|
||||||
onActivated(() => {
|
|
||||||
lastAccessedExploreRoute.value = route.path.replace(/(.*\/explore\/?)/, '')
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<CommonAlert v-if="!hideTagsTips" @close="hideTagsTips = true">
|
|
||||||
<p>{{ $t('tooltip.explore_tags_intro') }}</p>
|
|
||||||
</CommonAlert>
|
|
||||||
|
|
||||||
<TagCardPaginator v-bind="{ paginator }" />
|
|
||||||
</template>
|
|
|
@ -1,15 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
const instance = instanceStorage.value[currentServer.value]
|
|
||||||
try {
|
|
||||||
clearError({ redirect: currentUser.value ? '/home' : `/${currentServer.value}/public/local` })
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<MainContent text-base grid gap-3 m3>
|
|
||||||
<img v-if="instance !== undefined" rounded-3 :src="instance.thumbnail.url">
|
|
||||||
</MainContent>
|
|
||||||
</template>
|
|
|
@ -1,167 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { mastodon } from 'masto'
|
|
||||||
import AccountSearchResult from '~/components/list/AccountSearchResult.vue'
|
|
||||||
|
|
||||||
definePageMeta({
|
|
||||||
name: 'list-accounts',
|
|
||||||
})
|
|
||||||
|
|
||||||
const inputRef = ref<HTMLInputElement>()
|
|
||||||
defineExpose({
|
|
||||||
inputRef,
|
|
||||||
})
|
|
||||||
|
|
||||||
const params = useRoute().params
|
|
||||||
const listId = computed(() => params.list as string)
|
|
||||||
|
|
||||||
const mastoListAccounts = useMastoClient().v1.lists.$select(listId.value).accounts
|
|
||||||
const paginator = mastoListAccounts.list()
|
|
||||||
|
|
||||||
// the limit parameter is set to 1000 while masto.js issue is still open: https://github.com/neet/masto.js/issues/1282
|
|
||||||
const accountsInList = ref((await useMastoClient().v1.lists.$select(listId.value).accounts.list({ limit: 1000 })))
|
|
||||||
|
|
||||||
const paginatorRef = ref()
|
|
||||||
|
|
||||||
// search stuff
|
|
||||||
const query = ref('')
|
|
||||||
const el = ref<HTMLElement>()
|
|
||||||
const { accounts, loading } = useSearch(query, {
|
|
||||||
following: true,
|
|
||||||
})
|
|
||||||
const { focused } = useFocusWithin(el)
|
|
||||||
const index = ref(0)
|
|
||||||
|
|
||||||
function isInCurrentList(userId: string) {
|
|
||||||
return accountsInList.value.map(account => account.id).includes(userId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = computed(() => {
|
|
||||||
if (query.value.length === 0)
|
|
||||||
return []
|
|
||||||
return [...accounts.value]
|
|
||||||
})
|
|
||||||
|
|
||||||
// Reset index when results change
|
|
||||||
watch([results, focused], () => index.value = -1)
|
|
||||||
|
|
||||||
function addAccount(account: mastodon.v1.Account) {
|
|
||||||
try {
|
|
||||||
mastoListAccounts.create({ accountIds: [account.id] })
|
|
||||||
accountsInList.value.push(account)
|
|
||||||
paginatorRef.value?.createEntry(account)
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeAccount(account: mastodon.v1.Account) {
|
|
||||||
try {
|
|
||||||
mastoListAccounts.remove({ accountIds: [account.id] })
|
|
||||||
const accountIdsInList = accountsInList.value.map(account => account.id)
|
|
||||||
const index = accountIdsInList.indexOf(account.id)
|
|
||||||
if (index > -1) {
|
|
||||||
accountsInList.value.splice(index, 1)
|
|
||||||
paginatorRef.value?.removeEntry(account.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<!-- Search Accounts You Follow -->
|
|
||||||
<div ref="el" relative group>
|
|
||||||
<form
|
|
||||||
border="t base"
|
|
||||||
p-4 w-full
|
|
||||||
flex="~ wrap" relative gap-3
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
bg-base border="~ base" flex-1 h10 ps-1 pe-4 rounded-2 w-full flex="~ row"
|
|
||||||
items-center relative focus-within:box-shadow-outline gap-3
|
|
||||||
ps-4
|
|
||||||
>
|
|
||||||
<div i-ri:search-2-line pointer-events-none text-secondary mt="1px" class="rtl-flip" />
|
|
||||||
<input
|
|
||||||
ref="inputRef"
|
|
||||||
v-model="query"
|
|
||||||
bg-transparent
|
|
||||||
outline="focus:none"
|
|
||||||
ps-3
|
|
||||||
rounded-3
|
|
||||||
pb="1px"
|
|
||||||
h-full
|
|
||||||
w-full
|
|
||||||
placeholder-text-secondary
|
|
||||||
:placeholder="$t('list.search_following_placeholder')"
|
|
||||||
@keydown.esc.prevent="inputRef?.blur()"
|
|
||||||
@keydown.enter.prevent
|
|
||||||
>
|
|
||||||
<button v-if="query.length" btn-action-icon text-secondary @click="query = ''; inputRef?.focus()">
|
|
||||||
<span aria-hidden="true" class="i-ri:close-line" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Results -->
|
|
||||||
<div left-0 top-18 absolute w-full z-10 group-focus-within="pointer-events-auto visible" invisible pointer-events-none>
|
|
||||||
<div w-full bg-base border="~ dark" rounded-3 max-h-100 overflow-auto :class="results.length === 0 ? 'py2' : null">
|
|
||||||
<span v-if="query.trim().length === 0" block text-center text-sm text-secondary>
|
|
||||||
{{ $t('list.search_following_desc') }}
|
|
||||||
</span>
|
|
||||||
<template v-else-if="!loading">
|
|
||||||
<template v-if="results.length > 0">
|
|
||||||
<div
|
|
||||||
v-for="(result, i) in results"
|
|
||||||
:key="result.id"
|
|
||||||
flex
|
|
||||||
border="b base"
|
|
||||||
py2 px4
|
|
||||||
hover:bg-active justify-between transition-100 items-center
|
|
||||||
>
|
|
||||||
<AccountSearchResult
|
|
||||||
:active="index === parseInt(i.toString())"
|
|
||||||
:result="result"
|
|
||||||
:tabindex="focused ? 0 : -1"
|
|
||||||
/>
|
|
||||||
<CommonTooltip :content="isInCurrentList(result.id) ? $t('list.remove_account') : $t('list.add_account')">
|
|
||||||
<button
|
|
||||||
text-sm p2 border-1 transition-colors
|
|
||||||
border-dark
|
|
||||||
btn-action-icon
|
|
||||||
bg-base
|
|
||||||
:hover="isInCurrentList(result.id) ? 'text-red' : 'text-green'"
|
|
||||||
@click=" () => isInCurrentList(result.id) ? removeAccount(result.data) : addAccount(result.data) "
|
|
||||||
>
|
|
||||||
<span :class="isInCurrentList(result.id) ? 'i-ri:user-unfollow-line' : 'i-ri:user-add-line'" />
|
|
||||||
</button>
|
|
||||||
</CommonTooltip>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<span v-else block text-center text-sm text-secondary>
|
|
||||||
{{ $t('search.search_empty') }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<div v-else>
|
|
||||||
<SearchResultSkeleton />
|
|
||||||
<SearchResultSkeleton />
|
|
||||||
<SearchResultSkeleton />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CommonPaginator ref="paginatorRef" :paginator="paginator">
|
|
||||||
<template #default="{ item }">
|
|
||||||
<ListAccount
|
|
||||||
:account="item"
|
|
||||||
:list="listId"
|
|
||||||
hover-card
|
|
||||||
border="b base" py2 px4
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</CommonPaginator>
|
|
||||||
</template>
|
|
|
@ -1,19 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
useHydratedHead({
|
|
||||||
title: () => t('nav.lists'),
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<MainContent>
|
|
||||||
<template #title>
|
|
||||||
<NuxtLink to="/lists" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
|
|
||||||
<div i-ri:list-check />
|
|
||||||
<span text-lg font-bold>{{ t('nav.lists') }}</span>
|
|
||||||
</NuxtLink>
|
|
||||||
</template>
|
|
||||||
<NuxtPage v-if="isHydrated" />
|
|
||||||
</MainContent>
|
|
||||||
</template>
|
|
|
@ -1,144 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { mastodon } from 'masto'
|
|
||||||
|
|
||||||
definePageMeta({
|
|
||||||
middleware: 'auth',
|
|
||||||
})
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
const client = useMastoClient()
|
|
||||||
|
|
||||||
const paginator = client.v1.lists.list()
|
|
||||||
|
|
||||||
useHydratedHead({
|
|
||||||
title: () => t('nav.lists'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const paginatorRef = ref()
|
|
||||||
const inputRef = ref<HTMLInputElement>()
|
|
||||||
const actionError = ref<string | undefined>(undefined)
|
|
||||||
const busy = ref<boolean>(false)
|
|
||||||
const createText = ref('')
|
|
||||||
const enableSubmit = computed(() => createText.value.length > 0)
|
|
||||||
|
|
||||||
async function createList() {
|
|
||||||
if (busy.value || !enableSubmit.value)
|
|
||||||
return
|
|
||||||
|
|
||||||
busy.value = true
|
|
||||||
actionError.value = undefined
|
|
||||||
await nextTick()
|
|
||||||
try {
|
|
||||||
const newEntry = await client.v1.lists.create({
|
|
||||||
title: createText.value,
|
|
||||||
})
|
|
||||||
paginatorRef.value?.createEntry(newEntry)
|
|
||||||
createText.value = ''
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
actionError.value = (err as Error).message
|
|
||||||
nextTick(() => {
|
|
||||||
inputRef.value?.focus()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
busy.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearError(focusBtn: boolean) {
|
|
||||||
actionError.value = undefined
|
|
||||||
if (focusBtn) {
|
|
||||||
nextTick(() => {
|
|
||||||
inputRef.value?.focus()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateEntry(list: mastodon.v1.List) {
|
|
||||||
paginatorRef.value?.updateEntry(list)
|
|
||||||
}
|
|
||||||
function removeEntry(id: string) {
|
|
||||||
paginatorRef.value?.removeEntry(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
onDeactivated(() => clearError(false))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<CommonPaginator ref="paginatorRef" :paginator="paginator">
|
|
||||||
<template #default="{ item }">
|
|
||||||
<ListEntry
|
|
||||||
:model-value="item"
|
|
||||||
@update:model-value="updateEntry"
|
|
||||||
@list-removed="removeEntry"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<template #done>
|
|
||||||
<form
|
|
||||||
border="t base"
|
|
||||||
p-4 w-full
|
|
||||||
flex="~ wrap" relative gap-3
|
|
||||||
:aria-describedby="actionError ? 'create-list-error' : undefined"
|
|
||||||
:class="actionError ? 'border border-base border-rounded rounded-be-is-0 rounded-be-ie-0 border-b-unset border-$c-danger-active' : null"
|
|
||||||
@submit.prevent="createList"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
bg-base border="~ base" flex-1 h10 ps-1 pe-4 rounded-2 w-full flex="~ row"
|
|
||||||
items-center relative focus-within:box-shadow-outline gap-3
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref="inputRef"
|
|
||||||
v-model="createText"
|
|
||||||
bg-transparent
|
|
||||||
outline="focus:none"
|
|
||||||
px-4
|
|
||||||
pb="1px"
|
|
||||||
flex-1
|
|
||||||
placeholder-text-secondary
|
|
||||||
:placeholder="$t('list.list_title_placeholder')"
|
|
||||||
@keypress.enter="createList"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div flex="~ col" gap-y-4 gap-x-2 sm="~ justify-between flex-row">
|
|
||||||
<button flex="~ row" gap-x-2 items-center btn-solid :disabled="!enableSubmit || busy">
|
|
||||||
<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-material-symbols:playlist-add-rounded class="rtl-flip" />
|
|
||||||
{{ $t('list.create') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<CommonErrorMessage
|
|
||||||
v-if="actionError"
|
|
||||||
id="create-list-error"
|
|
||||||
described-by="create-list-failed"
|
|
||||||
class="rounded-bs-is-0 rounded-bs-ie-0 border-t-dashed m-b-2"
|
|
||||||
>
|
|
||||||
<header id="create-list-failed" flex justify-between>
|
|
||||||
<div flex items-center gap-x-2 font-bold>
|
|
||||||
<div aria-hidden="true" i-ri:error-warning-fill />
|
|
||||||
<p>{{ $t('list.error') }}</p>
|
|
||||||
</div>
|
|
||||||
<CommonTooltip placement="bottom" :content="$t('list.clear_error')">
|
|
||||||
<button
|
|
||||||
flex rounded-4 p1 hover:bg-active cursor-pointer transition-100 :aria-label="$t('list.clear_error')"
|
|
||||||
@click="clearError(true)"
|
|
||||||
>
|
|
||||||
<span aria-hidden="true" w="1.75em" h="1.75em" i-ri:close-line />
|
|
||||||
</button>
|
|
||||||
</CommonTooltip>
|
|
||||||
</header>
|
|
||||||
<ol ps-2 sm:ps-1>
|
|
||||||
<li flex="~ col sm:row" gap-y-1 sm:gap-x-2>
|
|
||||||
<strong sr-only>{{ $t('list.error_prefix') }}</strong>
|
|
||||||
<span>{{ actionError }}</span>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</CommonErrorMessage>
|
|
||||||
</template>
|
|
||||||
</CommonPaginator>
|
|
||||||
</template>
|
|
|
@ -1,38 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
const keys = useMagicKeys()
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
useHydratedHead({
|
|
||||||
title: () => t('nav.search'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const search = ref<{ input?: HTMLInputElement }>()
|
|
||||||
|
|
||||||
watchEffect(() => {
|
|
||||||
if (search.value?.input)
|
|
||||||
search.value?.input?.focus()
|
|
||||||
})
|
|
||||||
onActivated(() => search.value?.input?.focus())
|
|
||||||
onDeactivated(() => search.value?.input?.blur())
|
|
||||||
|
|
||||||
watch(keys['/'], (v) => {
|
|
||||||
// focus on input when '/' is up to avoid '/' being typed
|
|
||||||
if (!v)
|
|
||||||
search.value?.input?.focus()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<MainContent>
|
|
||||||
<template #title>
|
|
||||||
<NuxtLink to="/search" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
|
|
||||||
<div i-ri:search-line class="rtl-flip" />
|
|
||||||
<span>{{ $t('nav.search') }}</span>
|
|
||||||
</NuxtLink>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div px2 mt3>
|
|
||||||
<SearchWidget v-if="isHydrated" ref="search" m-1 />
|
|
||||||
</div>
|
|
||||||
</MainContent>
|
|
||||||
</template>
|
|
|
@ -1,51 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { mastodon } from 'masto'
|
|
||||||
|
|
||||||
definePageMeta({
|
|
||||||
name: 'tag',
|
|
||||||
})
|
|
||||||
|
|
||||||
const params = useRoute().params
|
|
||||||
const tagName = computed(() => params.tag as string)
|
|
||||||
|
|
||||||
const { client } = useMasto()
|
|
||||||
const { data: tag, refresh } = await useAsyncData(() => `tag-${tagName.value}`, () => client.value.v1.tags.$select(tagName.value).fetch(), { default: () => shallowRef() })
|
|
||||||
|
|
||||||
const paginator = client.value.v1.timelines.tag.$select(tagName.value).list()
|
|
||||||
const stream = useStreaming(client => client.hashtag.subscribe({ tag: tagName.value }))
|
|
||||||
|
|
||||||
if (tag.value) {
|
|
||||||
useHydratedHead({
|
|
||||||
title: () => `#${tag.value.name}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onReactivated(() => {
|
|
||||||
// Silently update data when reentering the page
|
|
||||||
// The user will see the previous content first, and any changes will be updated to the UI when the request is completed
|
|
||||||
refresh()
|
|
||||||
})
|
|
||||||
|
|
||||||
let followedTags: mastodon.v1.Tag[] | undefined
|
|
||||||
if (currentUser.value !== undefined) {
|
|
||||||
followedTags = (await useMasto().client.value.v1.followedTags.list({ limit: 0 }))
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<MainContent back>
|
|
||||||
<template #title>
|
|
||||||
<bdi text-lg font-bold>#{{ tagName }}</bdi>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #actions>
|
|
||||||
<template v-if="typeof tag?.following === 'boolean'">
|
|
||||||
<TagActionButton :tag="tag" @change="refresh()" />
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<slot>
|
|
||||||
<TimelinePaginator :followed-tags="followedTags" v-bind="{ paginator, stream }" context="public" />
|
|
||||||
</slot>
|
|
||||||
</MainContent>
|
|
||||||
</template>
|
|
|
@ -1,23 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
definePageMeta({
|
|
||||||
middleware: 'auth',
|
|
||||||
})
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
useHydratedHead({
|
|
||||||
title: () => t('nav.compose'),
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<MainContent>
|
|
||||||
<template #title>
|
|
||||||
<NuxtLink to="/compose" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
|
|
||||||
<div i-ri:quill-pen-line />
|
|
||||||
<span>{{ $t('nav.compose') }}</span>
|
|
||||||
</NuxtLink>
|
|
||||||
</template>
|
|
||||||
<PublishWidgetFull />
|
|
||||||
</MainContent>
|
|
||||||
</template>
|
|
|
@ -1,20 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
useHydratedHead({
|
|
||||||
title: () => t('nav.hashtags'),
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<MainContent>
|
|
||||||
<template #title>
|
|
||||||
<NuxtLink to="/hashtags" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
|
|
||||||
<div class="i-ri:hashtag" />
|
|
||||||
<span>{{ t('nav.hashtags') }}</span>
|
|
||||||
</NuxtLink>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<NuxtPage v-if="isHydrated && currentUser" />
|
|
||||||
</MainContent>
|
|
||||||
</template>
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue