Merge branch 'main' into feat/totally-hide-strict-filters

This commit is contained in:
Ayo 2023-01-19 17:35:13 +01:00
commit e7f9e08192
255 changed files with 7266 additions and 2066 deletions

19
.dockerignore Normal file
View file

@ -0,0 +1,19 @@
# Modified from .gitignore
node_modules
*.log
dist
.output
.nuxt
#.env # Not ignoring this file because it can contain build-related settings.
.DS_Store
.idea/
.vite-inspect
.netlify/
.eslintcache
public/shiki
public/emojis
*~
*swp
*swo

View file

@ -10,6 +10,8 @@ NUXT_CLOUDFLARE_API_TOKEN=
NUXT_STORAGE_DRIVER= NUXT_STORAGE_DRIVER=
NUXT_STORAGE_FS_BASE= NUXT_STORAGE_FS_BASE=
NUXT_ADMIN_KEY=
NUXT_PUBLIC_DISABLE_VERSION_CHECK= NUXT_PUBLIC_DISABLE_VERSION_CHECK=
NUXT_GITHUB_CLIENT_ID= NUXT_GITHUB_CLIENT_ID=

View file

@ -3,5 +3,8 @@
*.ico *.ico
*.toml *.toml
*.patch *.patch
*.txt
public/
https-dev-config/localhost.crt https-dev-config/localhost.crt
https-dev-config/localhost.key https-dev-config/localhost.key
Dockerfile

3
.github/FUNDING.yml vendored
View file

@ -1 +1,2 @@
github: [antfu, patak-dev, sxzz, danielroe] github: [elk-zone]
open_collective: elk

26
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,26 @@
<!-- Thank you for contributing! -->
### Description
<!-- Please insert your description here and provide especially info about the "what" this PR is solving -->
### Additional context
<!-- e.g. is there anything you'd like reviewers to focus on? -->
---
### What is the purpose of this pull request? <!-- (put an "X" next to an item) -->
- [ ] Bug fix
- [ ] New Feature
- [ ] Documentation update
- [ ] Translations update
- [ ] Other
### Before submitting the PR, please make sure you do the following
- [ ] Read the [Contributing Guidelines](https://github.com/elk-zone/elk/blob/main/CONTRIBUTING.md).
- [ ] Check that there isn't already a PR that solves the problem the same way to avoid creating a duplicate.
- [ ] Provide related snapshots or videos.
- [ ] Provide a description in this PR that addresses **what** the PR is solving, or reference the issue that it solves (e.g. `fixes #123`).

View file

@ -1,5 +1,7 @@
name: ci name: ci
permissions: {}
on: on:
push: push:
branches: branches:

View file

@ -1,5 +1,8 @@
name: Release name: Release
permissions:
contents: write
on: on:
push: push:
tags: tags:

1
.npmrc
View file

@ -1,3 +1,4 @@
shamefully-hoist=true shamefully-hoist=true
strict-peer-dependencies=false strict-peer-dependencies=false
shell-emulator=true shell-emulator=true
ignore-workspace-root-check=true

View file

@ -0,0 +1,7 @@
{
"bot": {
"issues": {
"trigger": "all-issues"
}
}
}

View file

@ -4,9 +4,13 @@ Hi! We are really excited that you are interested in contributing to Elk. Before
Refer also to https://github.com/antfu/contribute. Refer also to https://github.com/antfu/contribute.
## Set up your local development environment ### Online
The package manager used to install and link dependencies must be [pnpm](https://pnpm.io/) (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). 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).
[![Open in Codeflow](https://developer.stackblitz.com/img/open_in_codeflow.svg)](https://pr.new/elk-zone/elk)
### Local Setup
To develop and test the Elk package: To develop and test the Elk package:
@ -14,22 +18,37 @@ To develop and test the Elk package:
2. Ensure using the latest Node.js (16.x) 2. Ensure using the latest Node.js (16.x)
3. Elk uses pnpm v7, you must enable [Corepack](https://github.com/nodejs/corepack) by running `corepack enable`. 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
git checkout -b my-new-branch git checkout -b my-new-branch
``` ```
5. Run `pnpm i` in Elk's root folder 1. Run `pnpm i` in Elk's root folder
6. Run `pnpm nuxi prepare` in Elk's root folder 2. Run `pnpm nuxi prepare` in Elk's root folder
7. Run `pnpm dev` in Elk's root folder to start dev server or `pnpm dev:mocked` to start dev server with `@elkdev@universeodon.com` user. 3. Run `pnpm dev` in Elk's root folder to start dev server or `pnpm dev:mocked` to start dev server with `@elkdev@universeodon.com` user.
We recommend installing [ni](https://github.com/antfu/ni#ni), that will use the right package manager in each of your projects. If `ni` is installed, you can instead run:
```
ni
nr dev
```
### Testing
Elk uses [Vitest](https://vitest.dev). You can run the test suite with:
```
nr test
```
### Running PWA on dev server ### Running PWA on dev server
In order to run Elk with PWA enabled, run `pnpm run dev:pwa` in Elk's root folder to start dev server or `pnpm dev:mocked:pwa` to start dev server with `@elkdev@universeodon.com` user. In order to run Elk with PWA enabled, run `pnpm dev:pwa` in Elk's root folder to start dev server or `pnpm dev:mocked:pwa` to start dev server with `@elkdev@universeodon.com` user.
You should test the Elk PWA application on private browsing mode on any Chromium based browser: will not work on Firefox and Safari. You should test the Elk PWA application on private browsing mode on any Chromium based browser: will not work on Firefox and Safari.
@ -44,7 +63,11 @@ If not using private browsing mode, you will need to uninstall the PWA applicati
## CI errors ## CI errors
Sometimes when you push your changes, the CI can fail, but we cannot check the logs to see what went wrong, run the following commands on your local environment: Sometimes when you push your changes to create a new pull request (PR), the CI can fail, but we cannot check the logs to see what went wrong.
If you are getting **Semantic Pull Request** error, please check the [Semantic Pull Request](https://www.conventionalcommits.org/en/v1.0.0/#summary) documentation.
You can run the following commands on your local environment to fix CI errors:
- `pnpm test:unit` to run unit tests, maybe you also need to update snapshots - `pnpm test:unit` to run unit tests, maybe you also need to update snapshots
- `pnpm test:typecheck` to run TypeScript checks run on CI - `pnpm test:typecheck` to run TypeScript checks run on CI
@ -68,11 +91,11 @@ We are using [vue-i18n](https://vue-i18n.intlify.dev/) via [nuxt-i18n](https://i
### 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-US](../locales/en-US.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#L13) 3. Add the language to the `locales` array in [config/i18n.ts](./config/i18n.ts#L12), below `en` variants and `ar-EG`.
4. If the language is `right-to-left`, add `dir` option with `rtl` value, for example, for [ar-EG](../config/i18n.ts#L63) 4. If the language is `right-to-left`, add `dir` option with `rtl` value, for example, for [ar-EG](./config/i18n.ts#L27)
5. If the language requires special pluralization rules, add `pluralRule` callback option, for example, for [ar-EG](../config/i18n.ts#L64) 5. If the language requires special pluralization rules, add `pluralRule` callback option, for example, for [ar-EG](./config/i18n.ts#L27)
Check [Pluralization rule callback](https://vue-i18n.intlify.dev/guide/essentials/pluralization.html#custom-pluralization) for more info. Check [Pluralization rule callback](https://vue-i18n.intlify.dev/guide/essentials/pluralization.html#custom-pluralization) for more info.
@ -135,14 +158,14 @@ You can run this code in your browser console to see how it works:
Either **{0}** or **{v}** should be used with the exception being custom plurals entries using the `{n}` placeholder. Either **{0}** or **{v}** should be used with the exception being custom plurals entries using the `{n}` placeholder.
This is the full list of entries that will be available for number formatting in Elk: This is the full list of entries that will be available for number formatting in Elk:
- `action.boost_count` (no need to be included, we should use always `en-US` entry): `{0}` for formatted number and `{n}` for raw number - **{0} should be use** - `action.boost_count` (no need to be included, we should use always `en-US` entry): `{0}` for formatted number and `{n}` for raw number - **{0} should be used**
- `action.favourite_count` (no need to be included, we should use always `en-US` entry): `{0}` for formatted number and `{n}` for raw number - **{0} should be use** - `action.favourite_count` (no need to be included, we should use always `en-US` entry): `{0}` for formatted number and `{n}` for raw number - **{0} should be used**
- `action.reply_count` (no need to be included, we should use always `en-US` entry): `{0}` for formatted number and `{n}` for raw number - **{0} should be use** - `action.reply_count` (no need to be included, we should use always `en-US` entry): `{0}` for formatted number and `{n}` for raw number - **{0} should be used**
- `account.followers_count`: `{0}` for formatted number and `{n}` for raw number - **{0} should be use** - `account.followers_count`: `{0}` for formatted number and `{n}` for raw number - **{0} should be used**
- `account.following_count`: `{0}` for formatted number and `{n}` for raw number - **{0} should be use** - `account.following_count`: `{0}` for formatted number and `{n}` for raw number - **{0} should be used**
- `account.posts_count`: `{0}` for formatted number and `{n}` for raw number - **{0} should be use** - `account.posts_count`: `{0}` for formatted number and `{n}` for raw number - **{0} should be used**
- `compose.drafts`: `{v}` for formatted number and `{n}` for raw number - **{v} should be use** - `compose.drafts`: `{v}` for formatted number and `{n}` for raw number - **{v} should be used**
- `notification.followed_you_count`: `{0}` for formatted number and `{n}` for raw number - **{0} should be use** - `notification.followed_you_count`: `{0}` for formatted number and `{n}` for raw number - **{0} should be used**
- `status.poll.count`: `{0}` for formatted number and `{n}` for raw number - **{0} should be use** - `status.poll.count`: `{0}` for formatted number and `{n}` for raw number - **{0} should be used**
- `time_ago_options.*`: `{0}` for formatted number and `{n}` for raw number - **{0} should be use**: since numbers will be always small, we can also use `{n}` - `time_ago_options.*`: `{0}` for formatted number and `{n}` for raw number - **{0} should be used**: since numbers will be always small, we can also use `{n}`
- `timeline.show_new_items`: `{v}` for formatted number and `{n}` for raw number - **{v} should be use** - `timeline.show_new_items`: `{v}` for formatted number and `{n}` for raw number - **{v} should be used**

46
Dockerfile Normal file
View file

@ -0,0 +1,46 @@
FROM docker.io/library/node:lts-alpine AS base
# Prepare work directory
WORKDIR /elk
FROM base AS builder
# Prepare pnpm https://pnpm.io/installation#using-corepack
RUN corepack enable
# Prepare deps
RUN apk update
RUN apk add git --no-cache
# Prepare build deps ( ignore postinstall scripts for now )
COPY package.json ./
COPY pnpm-lock.yaml ./
COPY patches ./patches
RUN pnpm i --frozen-lockfile --ignore-scripts
# Copy all source files
COPY . ./
# Run full install with every postinstall script ( This needs project file )
RUN pnpm i --frozen-lockfile
# Build
RUN pnpm build
FROM base AS runner
ENV NODE_ENV=production
COPY --from=builder /elk/.output ./.output
EXPOSE 5314/tcp
ENV PORT=5314
# Specify container only environment variables ( can be overwritten by runtime env )
ENV NUXT_STORAGE_FS_BASE='/elk/data'
# Persistent storage data
VOLUME [ "/elk/data" ]
CMD ["node", ".output/server/index.mjs"]

View file

@ -1,32 +1,55 @@
# Elk
*A nimble Mastodon web client*
<p align="center"> <p align="center">
<a href="https://elk.zone" target="_blank" rel="noopener noreferrer"> <a href="https://elk.zone" target="_blank" rel="noopener noreferrer">
<img width="180" height="180" src="./elk.svg" alt="Elk logo"> <img width="160" height="160" src="./public/logo.svg" alt="Elk logo">
</a> </a>
</p> </p>
<h1 align="center"/>Elk <sup><em>alpha</em></sup></h1>
<p align="center">
A nimble Mastodon web client
</p>
<br/> <br/>
<p align="center"> <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://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://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> </p>
<br/> <br/>
# Elk is in early alpha ⚠️ <p align="center">
<a href="https://elk.zone/" target="_blank" rel="noopener noreferrer" >
<img src="./public/elk-og.png" alt="Elk screenshots" width="600" height="auto">
</a>
</p>
It is already quite usable, but it isn't ready for wide adoption yet. We recommend you to use if if you would like to help us building it. We appreciate your feedback and contributions. Check out the [Open Issues](https://github.com/elk-zone/elk/issues) and jump in the action. Join the [Elk discord server](https://chat.elk.zone) to chat with us and learn more about the project. ## ⚠️ Elk is in Alpha
The client is deployed on: It is already quite usable, but it isn't ready for wide adoption yet. We recommend you use it if you would like to help us build it. We appreciate your feedback and contributions. Check out the [Open Issues](https://github.com/elk-zone/elk/issues) and jump in the action. Join the [Elk discord server](https://chat.elk.zone) to chat with us and learn more about the project.
## Deployment
### Official Deployment
The Elk team maintains a deployment at:
- 🦌 Production: [elk.zone](https://elk.zone) - 🦌 Production: [elk.zone](https://elk.zone)
- 🐙 Canary: [main.elk.zone](https://main.elk.zone) (deploys on every commit to `main` branch) - 🐙 Canary: [main.elk.zone](https://main.elk.zone) (deploys on every commit to `main` branch)
You can share screenshots on social media but we prefer you avoid sharing this URL directly until the app is more polished. Feel free to share the URL with your friends and invite others you think could be interested in helping to improve Elk. ### Ecosystem
## Sponsors These are known deployments using Elk as an alternative Web client for Mastodon servers or as a base for other projects in the fediverse:
We want to thanks the generous sponsoring and help of: - [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
> **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" > <a href="https://nuxtlabs.com/" target="_blank" rel="noopener noreferrer" >
<img src="./images/nuxtlabs.svg" alt="NuxtLabs" height="85"> <img src="./images/nuxtlabs.svg" alt="NuxtLabs" height="85">
@ -37,7 +60,11 @@ We want to thanks the generous sponsoring and help of:
</a> </a>
<br><br> <br><br>
And all the companies and individuals sponsoring Elk Team members. If you're enjoying the app, consider sponsoring our team: 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) - [Anthony Fu](https://github.com/sponsors/antfu)
- [Daniel Roe](https://github.com/sponsors/danielroe) - [Daniel Roe](https://github.com/sponsors/danielroe)
@ -46,11 +73,11 @@ And all the companies and individuals sponsoring Elk Team members. If you're enj
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. 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 ## 📍 Roadmap
[Open board on Volta](https://volta.net/elk-zone/elk) [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.
@ -86,7 +113,7 @@ Elk uses [Vitest](https://vitest.dev). You can run the test suite with:
nr test nr test
``` ```
## Stack ## 🦄 Stack
- [Vite](https://vitejs.dev/) - Next Generation Frontend Tooling - [Vite](https://vitejs.dev/) - Next Generation Frontend Tooling
- [Nuxt](https://nuxt.com/) - The Intuitive Web Framework - [Nuxt](https://nuxt.com/) - The Intuitive Web Framework
@ -100,6 +127,12 @@ nr test
- [shiki](https://shiki.matsu.io/) - A beautiful 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 and push notifications - [vite-plugin-pwa](https://github.com/vite-pwa/vite-plugin-pwa) - Prompt for update and push notifications
## License ## 👨‍💻 Contributors
<a href="https://github.com/elk-zone/elk/graphs/contributors">
<img src="https://contrib.rocks/image?repo=elk-zone/elk" />
</a>
## 📄 License
[MIT](./LICENSE) &copy; 2022-PRESENT Elk contributors [MIT](./LICENSE) &copy; 2022-PRESENT Elk contributors

10
app.vue
View file

@ -2,6 +2,16 @@
setupPageHeader() setupPageHeader()
provideGlobalCommands() provideGlobalCommands()
const route = useRoute()
if (process.server && !route.path.startsWith('/settings')) {
useHead({
meta: [
{ property: 'og:url', content: `https://elk.zone${route.path}` },
],
})
}
// We want to trigger rerendering the page when account changes // We want to trigger rerendering the page when account changes
const key = computed(() => `${currentUser.value?.server ?? currentServer.value}:${currentUser.value?.account.id || ''}`) const key = computed(() => `${currentUser.value?.server ?? currentServer.value}:${currentUser.value?.account.id || ''}`)
</script> </script>

View file

@ -15,9 +15,11 @@ const error = $ref(false)
:key="account.avatar" :key="account.avatar"
width="400" width="400"
height="400" height="400"
:src="error ? 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' : account.avatar" select-none
:src="(error || !loaded) ? 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' : account.avatar"
:alt="$t('account.avatar_description', [account.username])" :alt="$t('account.avatar_description', [account.username])"
loading="lazy" loading="lazy"
class="account-avatar"
:class="(loaded ? 'bg-base' : 'bg-gray:10') + (square ? ' ' : ' rounded-full')" :class="(loaded ? 'bg-base' : 'bg-gray:10') + (square ? ' ' : ' rounded-full')"
:style="{ 'clip-path': square ? `url(#avatar-mask)` : 'none' }" :style="{ 'clip-path': square ? `url(#avatar-mask)` : 'none' }"
v-bind="$attrs" v-bind="$attrs"

View file

@ -8,18 +8,28 @@ const { account, command, context, ...props } = defineProps<{
command?: boolean command?: boolean
}>() }>()
const isSelf = $computed(() => currentUser.value?.account.id === account.id) const { t } = useI18n()
const isSelf = $(useSelfAccount(() => account))
const enable = $computed(() => !isSelf && currentUser.value) const enable = $computed(() => !isSelf && currentUser.value)
const relationship = $computed(() => props.relationship || useRelationship(account).value) const relationship = $computed(() => props.relationship || useRelationship(account).value)
const masto = useMasto() const { client } = $(useMasto())
async function toggleFollow() { async function toggleFollow() {
if (relationship!.following) {
if (await openConfirmDialog({
title: t('confirm.unfollow.title'),
confirm: t('confirm.unfollow.confirm'),
cancel: t('confirm.unfollow.cancel'),
}) !== 'confirm')
return
}
relationship!.following = !relationship!.following relationship!.following = !relationship!.following
try { try {
const newRel = await masto.v1.accounts[relationship!.following ? 'follow' : 'unfollow'](account.id) const newRel = await client.v1.accounts[relationship!.following ? 'follow' : 'unfollow'](account.id)
Object.assign(relationship!, newRel) Object.assign(relationship!, newRel)
} }
catch { catch (err) {
console.error(err)
// TODO error handling // TODO error handling
relationship!.following = !relationship!.following relationship!.following = !relationship!.following
} }
@ -28,10 +38,11 @@ async function toggleFollow() {
async function unblock() { async function unblock() {
relationship!.blocking = false relationship!.blocking = false
try { try {
const newRel = await masto.v1.accounts.unblock(account.id) const newRel = await client.v1.accounts.unblock(account.id)
Object.assign(relationship!, newRel) Object.assign(relationship!, newRel)
} }
catch { catch (err) {
console.error(err)
// TODO error handling // TODO error handling
relationship!.blocking = true relationship!.blocking = true
} }
@ -40,17 +51,16 @@ async function unblock() {
async function unmute() { async function unmute() {
relationship!.muting = false relationship!.muting = false
try { try {
const newRel = await masto.v1.accounts.unmute(account.id) const newRel = await client.v1.accounts.unmute(account.id)
Object.assign(relationship!, newRel) Object.assign(relationship!, newRel)
} }
catch { catch (err) {
console.error(err)
// TODO error handling // TODO error handling
relationship!.muting = true relationship!.muting = true
} }
} }
const { t } = useI18n()
useCommand({ useCommand({
scope: 'Actions', scope: 'Actions',
order: -2, order: -2,

View file

@ -6,6 +6,8 @@ const { account } = defineProps<{
command?: boolean command?: boolean
}>() }>()
const { client } = $(useMasto())
const { t } = useI18n() const { t } = useI18n()
const createdAt = $(useFormattedDateTime(() => account.createdAt, { const createdAt = $(useFormattedDateTime(() => account.createdAt, {
@ -14,13 +16,20 @@ const createdAt = $(useFormattedDateTime(() => account.createdAt, {
year: 'numeric', year: 'numeric',
})) }))
const relationship = $(useRelationship(account))
const namedFields = ref<mastodon.v1.AccountField[]>([]) const namedFields = ref<mastodon.v1.AccountField[]>([])
const iconFields = ref<mastodon.v1.AccountField[]>([]) const iconFields = ref<mastodon.v1.AccountField[]>([])
const hasHeader = $computed(() => !account.header.endsWith('/original/missing.png'))
function getFieldIconTitle(fieldName: string) { function getFieldIconTitle(fieldName: string) {
return fieldName === 'Joined' ? t('account.joined') : fieldName return fieldName === 'Joined' ? t('account.joined') : fieldName
} }
function getNotificationIconTitle() {
return relationship?.notifying ? t('account.notifications_on_post_disable', { username: `@${account.username}` }) : t('account.notifications_on_post_enable', { username: `@${account.username}` })
}
function previewHeader() { function previewHeader() {
openMediaPreview([{ openMediaPreview([{
id: `${account.acct}:header`, id: `${account.acct}:header`,
@ -39,6 +48,18 @@ function previewAvatar() {
}]) }])
} }
async function toggleNotifications() {
relationship!.notifying = !relationship?.notifying
try {
const newRel = await client.v1.accounts.follow(account.id, { notify: relationship?.notifying })
Object.assign(relationship!, newRel)
}
catch {
// TODO error handling
relationship!.notifying = !relationship?.notifying
}
}
watchEffect(() => { watchEffect(() => {
const named: mastodon.v1.AccountField[] = [] const named: mastodon.v1.AccountField[] = []
const icons: mastodon.v1.AccountField[] = [] const icons: mastodon.v1.AccountField[] = []
@ -59,19 +80,20 @@ watchEffect(() => {
iconFields.value = icons iconFields.value = icons
}) })
const isSelf = $computed(() => currentUser.value?.account.id === account.id) const isSelf = $(useSelfAccount(() => account))
const isNotifiedOnPost = $computed(() => !!relationship?.notifying)
</script> </script>
<template> <template>
<div flex flex-col> <div flex flex-col>
<button border="b base" z-1> <component :is="hasHeader ? 'button' : 'div'" border="b base" z-1 @click="hasHeader ? previewHeader() : undefined">
<img h-50 height="200" w-full object-cover :src="account.header" :alt="t('account.profile_description', [account.username])" @click="previewHeader"> <img h-50 height="200" w-full object-cover :src="account.header" :alt="t('account.profile_description', [account.username])">
</button> </component>
<div p4 mt--18 flex flex-col gap-4> <div p4 mt--18 flex flex-col gap-4>
<div relative> <div relative>
<div flex="~ col gap-2 1"> <div flex="~ col gap-2 1">
<button w-30 h-30 rounded-full border-4 border-bg-base z-2 @click="previewAvatar"> <button :class="{ 'rounded-full': !isSelf, 'squircle': isSelf }" w-30 h-30 p1 bg-base border-bg-base z-2 @click="previewAvatar">
<AccountAvatar :account="account" hover:opacity-90 transition-opacity /> <AccountAvatar :square="isSelf" :account="account" hover:opacity-90 transition-opacity />
</button> </button>
<div flex="~ col gap1"> <div flex="~ col gap1">
<div flex justify-between> <div flex justify-between>
@ -83,6 +105,18 @@ const isSelf = $computed(() => currentUser.value?.account.id === account.id)
</div> </div>
<div absolute top-18 inset-ie-0 flex gap-2 items-center> <div absolute top-18 inset-ie-0 flex gap-2 items-center>
<AccountMoreButton :account="account" :command="command" /> <AccountMoreButton :account="account" :command="command" />
<CommonTooltip v-if="!isSelf && relationship?.following" :content="getNotificationIconTitle()">
<button
:aria-pressed="isNotifiedOnPost"
:aria-label="t('account.notifications_on_post_enable', { username: `@${account.username}` })"
rounded-full p2 border-1 transition-colors
:class="isNotifiedOnPost ? 'text-primary border-primary hover:bg-red/20 hover:text-red hover:border-red' : 'border-base hover:text-primary'"
@click="toggleNotifications"
>
<span v-if="isNotifiedOnPost" i-ri:notification-4-fill block text-current />
<span v-else i-ri-notification-4-line block text-current />
</button>
</CommonTooltip>
<AccountFollowButton :account="account" :command="command" /> <AccountFollowButton :account="account" :command="command" />
<!-- Edit profile --> <!-- Edit profile -->
<NuxtLink <NuxtLink
@ -93,11 +127,6 @@ const isSelf = $computed(() => currentUser.value?.account.id === account.id)
> >
{{ $t('settings.profile.appearance.title') }} {{ $t('settings.profile.appearance.title') }}
</NuxtLink> </NuxtLink>
<!-- <button flex gap-1 items-center w-full rounded op75 hover="op100 text-purple" group>
<div rounded p2 group-hover="bg-rose/10">
<div i-ri:bell-line />
</div>
</button> -->
</div> </div>
</div> </div>
<div v-if="account.note" max-h-100 overflow-y-auto> <div v-if="account.note" max-h-100 overflow-y-auto>

View file

@ -20,7 +20,7 @@ defineOptions({
<AccountHoverWrapper :disabled="!hoverCard" :account="account"> <AccountHoverWrapper :disabled="!hoverCard" :account="account">
<AccountBigAvatar :account="account" shrink-0 :square="square" /> <AccountBigAvatar :account="account" shrink-0 :square="square" />
</AccountHoverWrapper> </AccountHoverWrapper>
<div flex="~ col" shrink pt-1 h-full overflow-hidden justify-center leading-none> <div flex="~ col" shrink pt-1 h-full overflow-hidden justify-center leading-none select-none>
<div flex="~" gap-2> <div flex="~" gap-2>
<AccountDisplayName :account="account" font-bold line-clamp-1 ws-pre-wrap break-all text-lg /> <AccountDisplayName :account="account" font-bold line-clamp-1 ws-pre-wrap break-all text-lg />
<AccountBotIndicator v-if="account.bot" text-xs /> <AccountBotIndicator v-if="account.bot" text-xs />

View file

@ -12,7 +12,7 @@ const { link = true, avatar = true } = defineProps<{
<AccountHoverWrapper :account="account"> <AccountHoverWrapper :account="account">
<NuxtLink <NuxtLink
:to="link ? getAccountRoute(account) : undefined" :to="link ? getAccountRoute(account) : undefined"
:class="link ? 'text-link-rounded ms-0 ps-0' : ''" :class="link ? 'text-link-rounded -ml-1.8rem pl-1.8rem rtl-(ml0 pl-0.5rem -mr-1.8rem pr-1.8rem)' : ''"
min-w-0 flex gap-2 items-center min-w-0 flex gap-2 items-center
> >
<AccountAvatar v-if="avatar" :account="account" w-5 h-5 /> <AccountAvatar v-if="avatar" :account="account" w-5 h-5 />

View file

@ -7,39 +7,61 @@ const { account } = defineProps<{
}>() }>()
let relationship = $(useRelationship(account)) let relationship = $(useRelationship(account))
const isSelf = $computed(() => currentUser.value?.account.id === account.id) const isSelf = $(useSelfAccount(() => account))
const { t } = useI18n()
const { client } = $(useMasto())
const masto = useMasto()
const toggleMute = async () => { const toggleMute = async () => {
// TODO: Add confirmation if (!relationship!.muting && await openConfirmDialog({
title: t('confirm.mute_account.title', [account.acct]),
confirm: t('confirm.mute_account.confirm'),
cancel: t('confirm.mute_account.cancel'),
}) !== 'confirm')
return
relationship!.muting = !relationship!.muting relationship!.muting = !relationship!.muting
relationship = relationship!.muting relationship = relationship!.muting
? await masto.v1.accounts.mute(account.id, { ? await client.v1.accounts.mute(account.id, {
// TODO support more options // TODO support more options
}) })
: await masto.v1.accounts.unmute(account.id) : await client.v1.accounts.unmute(account.id)
} }
const toggleBlockUser = async () => { const toggleBlockUser = async () => {
// TODO: Add confirmation if (!relationship!.blocking && await openConfirmDialog({
title: t('confirm.block_account.title', [account.acct]),
confirm: t('confirm.block_account.confirm'),
cancel: t('confirm.block_account.cancel'),
}) !== 'confirm')
return
relationship!.blocking = !relationship!.blocking relationship!.blocking = !relationship!.blocking
relationship = await masto.v1.accounts[relationship!.blocking ? 'block' : 'unblock'](account.id) relationship = await client.v1.accounts[relationship!.blocking ? 'block' : 'unblock'](account.id)
} }
const toggleBlockDomain = async () => { const toggleBlockDomain = async () => {
// TODO: Add confirmation if (!relationship!.domainBlocking && await openConfirmDialog({
title: t('confirm.block_domain.title', [getServerName(account)]),
confirm: t('confirm.block_domain.confirm'),
cancel: t('confirm.block_domain.cancel'),
}) !== 'confirm')
return
relationship!.domainBlocking = !relationship!.domainBlocking relationship!.domainBlocking = !relationship!.domainBlocking
await masto.v1.domainBlocks[relationship!.domainBlocking ? 'block' : 'unblock'](getServerName(account)) await client.v1.domainBlocks[relationship!.domainBlocking ? 'block' : 'unblock'](getServerName(account))
} }
const toggleReblogs = async () => { const toggleReblogs = async () => {
// TODO: Add confirmation if (!relationship!.showingReblogs && await openConfirmDialog({
title: t('confirm.show_reblogs.title', [account.acct]),
confirm: t('confirm.show_reblogs.confirm'),
cancel: t('confirm.show_reblogs.cancel'),
}) !== 'confirm')
return
const showingReblogs = !relationship?.showingReblogs const showingReblogs = !relationship?.showingReblogs
relationship = await masto.v1.accounts.follow(account.id, { reblogs: showingReblogs }) relationship = await client.v1.accounts.follow(account.id, { reblogs: showingReblogs })
} }
</script> </script>
@ -80,14 +102,14 @@ const toggleReblogs = async () => {
icon="i-ri:repeat-line" icon="i-ri:repeat-line"
:text="$t('menu.show_reblogs', [`@${account.acct}`])" :text="$t('menu.show_reblogs', [`@${account.acct}`])"
:command="command" :command="command"
@click="toggleReblogs" @click="toggleReblogs()"
/> />
<CommonDropdownItem <CommonDropdownItem
v-else v-else
:text="$t('menu.hide_reblogs', [`@${account.acct}`])" :text="$t('menu.hide_reblogs', [`@${account.acct}`])"
icon="i-ri:repeat-line" icon="i-ri:repeat-line"
:command="command" :command="command"
@click="toggleReblogs" @click="toggleReblogs()"
/> />
<CommonDropdownItem <CommonDropdownItem
@ -95,14 +117,14 @@ const toggleReblogs = async () => {
:text="$t('menu.mute_account', [`@${account.acct}`])" :text="$t('menu.mute_account', [`@${account.acct}`])"
icon="i-ri:volume-up-fill" icon="i-ri:volume-up-fill"
:command="command" :command="command"
@click="toggleMute" @click="toggleMute()"
/> />
<CommonDropdownItem <CommonDropdownItem
v-else v-else
:text="$t('menu.unmute_account', [`@${account.acct}`])" :text="$t('menu.unmute_account', [`@${account.acct}`])"
icon="i-ri:volume-mute-line" icon="i-ri:volume-mute-line"
:command="command" :command="command"
@click="toggleMute" @click="toggleMute()"
/> />
<CommonDropdownItem <CommonDropdownItem
@ -110,14 +132,14 @@ const toggleReblogs = async () => {
:text="$t('menu.block_account', [`@${account.acct}`])" :text="$t('menu.block_account', [`@${account.acct}`])"
icon="i-ri:forbid-2-line" icon="i-ri:forbid-2-line"
:command="command" :command="command"
@click="toggleBlockUser" @click="toggleBlockUser()"
/> />
<CommonDropdownItem <CommonDropdownItem
v-else v-else
:text="$t('menu.unblock_account', [`@${account.acct}`])" :text="$t('menu.unblock_account', [`@${account.acct}`])"
icon="i-ri:checkbox-circle-line" icon="i-ri:checkbox-circle-line"
:command="command" :command="command"
@click="toggleBlockUser" @click="toggleBlockUser()"
/> />
<template v-if="getServerName(account) !== currentServer"> <template v-if="getServerName(account) !== currentServer">
@ -126,14 +148,14 @@ const toggleReblogs = async () => {
:text="$t('menu.block_domain', [getServerName(account)])" :text="$t('menu.block_domain', [getServerName(account)])"
icon="i-ri:shut-down-line" icon="i-ri:shut-down-line"
:command="command" :command="command"
@click="toggleBlockDomain" @click="toggleBlockDomain()"
/> />
<CommonDropdownItem <CommonDropdownItem
v-else v-else
:text="$t('menu.unblock_domain', [getServerName(account)])" :text="$t('menu.unblock_domain', [getServerName(account)])"
icon="i-ri:restart-line" icon="i-ri:restart-line"
:command="command" :command="command"
@click="toggleBlockDomain" @click="toggleBlockDomain()"
/> />
</template> </template>
</template> </template>

View file

@ -1,10 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Paginator, mastodon } from 'masto' import type { Paginator, mastodon } from 'masto'
const { paginator } = defineProps<{ const { paginator, account, context } = defineProps<{
paginator: Paginator<mastodon.v1.Account[], mastodon.DefaultPaginationParams> paginator: Paginator<mastodon.v1.Account[], mastodon.DefaultPaginationParams>
context?: 'following' | 'followers'
account?: mastodon.v1.Account
relationshipContext?: 'followedBy' | 'following' relationshipContext?: 'followedBy' | 'following'
}>() }>()
const fallbackContext = $computed(() => {
return ['following', 'followers'].includes(context!)
})
const showOriginSite = $computed(() =>
account && account.id !== currentUser.value?.account.id && getServerName(account) !== currentServer.value,
)
</script> </script>
<template> <template>
@ -17,5 +26,18 @@ const { paginator } = defineProps<{
border="b base" py2 px4 border="b base" py2 px4
/> />
</template> </template>
<template v-if="fallbackContext && showOriginSite" #done>
<div p5 text-secondary text-center flex flex-col items-center gap1>
<span italic>{{ $t(`account.view_other_${context}`) }}</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>
</div>
</template>
</CommonPaginator> </CommonPaginator>
</template> </template>

View file

@ -4,6 +4,8 @@ import type { mastodon } from 'masto'
defineProps<{ defineProps<{
account: mastodon.v1.Account account: mastodon.v1.Account
}>() }>()
const userSettings = useUserSettings()
</script> </script>
<template> <template>
@ -38,7 +40,7 @@ defineProps<{
</template> </template>
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
v-if="!getWellnessSetting('hideFollowerCount')" v-if="!getPreferences(userSettings, 'hideFollowerCount')"
:to="getAccountFollowersRoute(account)" :to="getAccountFollowersRoute(account)"
replace text-secondary replace text-secondary
exact-active-class="text-primary" exact-active-class="text-primary"

View file

@ -20,7 +20,7 @@ function close() {
<div> <div>
<slot /> <slot />
</div> </div>
<button text-xl hover:text-primary bg-hover-overflow w="1.4em" h="1.4em" @click="close()"> <button text-xl hover:text-primary bg-hover-overflow w="1.2em" h="1.2em" @click="close()">
<div i-ri:close-line /> <div i-ri:close-line />
</button> </button>
</div> </div>

View file

@ -88,17 +88,19 @@ watch(file, (image, _, onCleanup) => {
w-full w-full
h-full h-full
> >
<div absolute bg="black/50" text-white rounded-full text-xl w12 h12 flex justify-center items-center hover="bg-black/40 text-primary"> <span absolute bg="black/50" text-white rounded-full text-xl w12 h12 flex justify-center items-center hover="bg-black/40 text-primary">
<div i-ri:upload-line /> <span block i-ri:upload-line />
</div> </span>
<div <span
v-if="loading" v-if="loading"
absolute inset-0 absolute inset-0
bg="black/30" text-white bg="black/30" text-white
flex justify-center items-center flex justify-center items-center
> >
<div class="i-ri:loader-4-line animate-spin animate-duration-[2.5s]" text-4xl /> <span class="animate-spin animate-duration-[2.5s] preserve-3d">
</div> <span block i-ri:loader-4-line text-4xl />
</span>
</span>
</label> </label>
</template> </template>

View file

@ -0,0 +1,13 @@
<script setup lang="ts">
const {
zIndex = 100,
background = 'transparent',
} = $defineProps<{
zIndex?: number
background?: string
}>()
</script>
<template>
<div fixed top-0 bottom-0 left-0 right-0 :style="{ background, zIndex }" />
</template>

View file

@ -44,7 +44,7 @@ defineSlots<{
const { t } = useI18n() const { t } = useI18n()
const { items, prevItems, update, state, endAnchor, error } = usePaginator(paginator, stream, eventType, preprocess, context) const { items, prevItems, update, state, endAnchor, error } = usePaginator(paginator, $$(stream), eventType, preprocess, context)
</script> </script>
<template> <template>

View file

@ -43,7 +43,7 @@ useCommands(() => command
exact-active-class="children:(text-secondary !border-primary !op100 !text-base)" exact-active-class="children:(text-secondary !border-primary !op100 !text-base)"
@click="!preventScrollTop && $scrollToTop()" @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> <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 || '&nbsp;' }}</span>
</NuxtLink> </NuxtLink>
<div v-else flex flex-auto sm:px6 px2 xl:pb4 xl:pt5> <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> <span ws-nowrap mxa sm:px2 sm:py3 py2 text-center text-secondary-light op50>{{ option.display }}</span>

View file

@ -9,6 +9,7 @@ defineProps<{
<template> <template>
<VTooltip <VTooltip
v-bind="$attrs" v-bind="$attrs"
auto-hide
> >
<slot /> <slot />
<template #popper> <template #popper>

View file

@ -19,11 +19,12 @@ const historyNum = $computed(() => {
}) })
const sparklineEl = $ref<SVGSVGElement>() const sparklineEl = $ref<SVGSVGElement>()
const sparklineFn = typeof sparkline !== 'function' ? (sparkline as any).default : sparkline
watch([$$(historyNum), $$(sparklineEl)], ([historyNum, sparklineEl]) => { watch([$$(historyNum), $$(sparklineEl)], ([historyNum, sparklineEl]) => {
if (!sparklineEl) if (!sparklineEl)
return return
sparkline(sparklineEl, historyNum) sparklineFn(sparklineEl, historyNum)
}) })
</script> </script>

View file

@ -1,7 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
defineProps<{
smallScreen: boolean
}>()
const online = useOnline() const online = useOnline()
</script> </script>

View file

@ -42,6 +42,7 @@ useCommand({
<div <div
v-bind="$attrs" ref="el" v-bind="$attrs" ref="el"
flex gap-3 items-center cursor-pointer px4 py3 flex gap-3 items-center cursor-pointer px4 py3
select-none
hover-bg-active hover-bg-active
:aria-label="text" :aria-label="text"
@click="handleClick" @click="handleClick"

View file

@ -18,5 +18,6 @@ const highlighted = computed(() => {
</script> </script>
<template> <template>
<pre class="code-block" v-html="highlighted" /> <pre v-if="lang" class="code-block" v-html="highlighted" />
<pre v-else class="code-block">{{ raw }}</pre>
</template> </template>

View file

@ -0,0 +1,11 @@
<script setup lang="ts">
defineProps<{
replying?: boolean
}>()
</script>
<template>
<p flex="~ gap-1 wrap" items-center text-sm :class="{ 'zen-none': !replying }">
<span i-ri-arrow-right-line ml--1 text-secondary-light /><slot />
</p>
</template>

View file

@ -23,7 +23,7 @@ const emit = defineEmits<{
</p> </p>
<p> <p>
{{ $t('help.desc_para4') }} {{ $t('help.desc_para4') }}
<NuxtLink font-bold text-primary href="/m.webtoo.ls/@elk" target="_blank"> <NuxtLink font-bold text-primary href="https://github.com/elk-zone/elk" target="_blank">
{{ $t('help.desc_para5') }} {{ $t('help.desc_para5') }}
</NuxtLink> </NuxtLink>
{{ $t('help.desc_para6') }} {{ $t('help.desc_para6') }}
@ -37,7 +37,9 @@ const emit = defineEmits<{
</template> </template>
</p> </p>
<p italic flex justify-center w-full> <p italic flex justify-center w-full>
<span text-xl font-script>The Elk Team</span> <NuxtLink href="https://github.com/sponsors/elk-zone" target="_blank">
<span text-xl font-script hover:text-primary transition duration-300>The Elk Team</span>
</NuxtLink>
</p> </p>
<button btn-solid mxa tabindex="2" @click="emit('close')"> <button btn-solid mxa tabindex="2" @click="emit('close')">

View file

@ -12,13 +12,12 @@ defineProps<{
<div <div
sticky top-0 z10 backdrop-blur sticky top-0 z10 backdrop-blur
pt="[env(safe-area-inset-top,0)]" pt="[env(safe-area-inset-top,0)]"
border="b base" bg="[rgba(var(--c-bg-base-rgb),0.7)]" border="b base" bg="[rgba(var(--rbg-bg-base),0.7)]"
> >
<div xl:hidden flex justify-between px5 py2> <div flex justify-between px5 py2 :class="{ 'xl:hidden': $route.name !== 'tag' }" data-tauri-drag-region>
<div flex gap-3 items-center overflow-hidden py2> <div flex gap-3 items-center overflow-hidden py2>
<NuxtLink <NuxtLink
v-if="backOnSmallScreen || back" flex="~ gap1" items-center btn-text p-0 v-if="backOnSmallScreen || back" flex="~ gap1" items-center btn-text p-0 xl:hidden
:class="{ 'lg:hidden': backOnSmallScreen }"
:aria-label="$t('nav.back')" :aria-label="$t('nav.back')"
@click="$router.go(-1)" @click="$router.go(-1)"
> >
@ -32,13 +31,13 @@ defineProps<{
<div flex items-center flex-shrink-0 gap-x-2> <div flex items-center flex-shrink-0 gap-x-2>
<slot name="actions" /> <slot name="actions" />
<PwaBadge lg:hidden /> <PwaBadge lg:hidden />
<NavUser v-if="isMastoInitialised" /> <NavUser v-if="isHydrated" />
<NavUserSkeleton v-else /> <NavUserSkeleton v-else />
</div> </div>
</div> </div>
<slot name="header" /> <slot name="header" />
</div> </div>
<div hidden xl:block h-6 /> <div :class="{ 'xl:block': $route.name !== 'tag' }" hidden h-6 />
<slot /> <slot />
</div> </div>
</template> </template>

View file

@ -18,10 +18,10 @@ const emit = defineEmits<{
</div> </div>
<div flex justify-end gap-2> <div flex justify-end gap-2>
<button btn-text @click="emit('choice', 'cancel')"> <button btn-text @click="emit('choice', 'cancel')">
{{ cancel || $t('common.confirm_dialog.cancel') }} {{ cancel || $t('confirm.common.cancel') }}
</button> </button>
<button btn-solid @click="emit('choice', 'confirm')"> <button btn-solid @click="emit('choice', 'confirm')">
{{ confirm || $t('common.confirm_dialog.confirm') }} {{ confirm || $t('confirm.common.confirm') }}
</button> </button>
</div> </div>
</div> </div>

View file

@ -51,7 +51,7 @@ const handleFavouritedBoostedByClose = () => {
</script> </script>
<template> <template>
<template v-if="isMastoInitialised"> <template v-if="isHydrated">
<ModalDialog v-model="isSigninDialogOpen" py-4 px-8 max-w-125> <ModalDialog v-model="isSigninDialogOpen" py-4 px-8 max-w-125>
<UserSignIn /> <UserSignIn />
</ModalDialog> </ModalDialog>
@ -71,9 +71,10 @@ const handleFavouritedBoostedByClose = () => {
/> />
</ModalDialog> </ModalDialog>
<ModalDialog <ModalDialog
v-model="isMediaPreviewOpen" :model-value="isMediaPreviewOpen"
w-full max-w-full h-full max-h-full w-full max-w-full h-full max-h-full
bg-transparent border-0 shadow-none bg-transparent border-0 shadow-none
@update:model-value="closeMediaPreview"
> >
<ModalMediaPreview v-if="isMediaPreviewOpen" @close="closeMediaPreview()" /> <ModalMediaPreview v-if="isMediaPreviewOpen" @close="closeMediaPreview()" />
</ModalDialog> </ModalDialog>

View file

@ -53,6 +53,10 @@ const { modelValue: visible } = defineModel<{
modelValue: boolean modelValue: boolean
}>() }>()
defineOptions({
inheritAttrs: false,
})
const deactivated = useDeactivated() const deactivated = useDeactivated()
const route = useRoute() const route = useRoute()
@ -132,12 +136,6 @@ useEventListener('keydown', (e: KeyboardEvent) => {
}) })
</script> </script>
<script lang="ts">
export default {
inheritAttrs: false,
}
</script>
<template> <template>
<Teleport to="body"> <Teleport to="body">
<!-- Dialog component --> <!-- Dialog component -->

View file

@ -54,7 +54,21 @@ onUnmounted(() => locked.value = false)
</button> </button>
<div flex flex-row items-center mxa> <div flex flex-row items-center mxa>
<div flex="~ col center" max-h-full max-w-full>
<ModalMediaPreviewCarousel v-model="index" :media="mediaPreviewList" @close="emit('close')" /> <ModalMediaPreviewCarousel v-model="index" :media="mediaPreviewList" @close="emit('close')" />
<div bg="black/30" dark:bg="white/10" ms-4 mb-6 mt-4 text-white rounded-full flex="~ center shrink-0" overflow-hidden>
<div v-if="mediaPreviewList.length > 1" p="y-1 x-3" rounded-r-0 shrink-0>
{{ index + 1 }} / {{ mediaPreviewList.length }}
</div>
<p
v-if="current.description" bg="dark/30" dark:bg="white/10" p="y-1 x-3" rounded-ie-full line-clamp-1
ws-pre-wrap break-all :title="current.description" w-full
>
{{ current.description }}
</p>
</div>
</div>
</div> </div>
<div absolute top-0 w-full flex justify-between> <div absolute top-0 w-full flex justify-between>
@ -64,17 +78,6 @@ onUnmounted(() => locked.value = false)
> >
<div i-ri:close-line text-white /> <div i-ri:close-line text-white />
</button> </button>
<div bg="black/30" dark:bg="white/10" ms-4 my-auto text-white rounded-full flex="~ center" overflow-hidden>
<div v-if="mediaPreviewList.length > 1" p="y-1 x-2" rounded-r-0 shrink-0>
{{ index + 1 }} / {{ mediaPreviewList.length }}
</div>
<p
v-if="current.description" bg="dark/30" dark:bg="white/10" p="y-1 x-2" rounded-ie-full line-clamp-1
ws-pre-wrap break-all :title="current.description" w-full
>
{{ current.description }}
</p>
</div>
</div> </div>
</div> </div>
</template> </template>

View file

@ -10,7 +10,7 @@ const moreMenuVisible = ref(false)
class="after-content-empty after:(h-[calc(100%+0.5px)] w-0.1px pointer-events-none)" class="after-content-empty after:(h-[calc(100%+0.5px)] w-0.1px pointer-events-none)"
> >
<!-- These weird styles above are used for scroll locking, don't change it unless you know exactly what you're doing. --> <!-- These weird styles above are used for scroll locking, don't change it unless you know exactly what you're doing. -->
<template v-if="isMastoInitialised && currentUser"> <template v-if="currentUser">
<NuxtLink to="/home" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop"> <NuxtLink to="/home" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
<div i-ri:home-5-line /> <div i-ri:home-5-line />
</NuxtLink> </NuxtLink>
@ -24,7 +24,7 @@ const moreMenuVisible = ref(false)
<div i-ri:at-line /> <div i-ri:at-line />
</NuxtLink> </NuxtLink>
</template> </template>
<template v-if="isMastoInitialised && !currentUser"> <template v-else>
<NuxtLink :to="`/${currentServer}/explore`" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop"> <NuxtLink :to="`/${currentServer}/explore`" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
<div i-ri:hashtag /> <div i-ri:hashtag />
</NuxtLink> </NuxtLink>

View file

@ -4,6 +4,8 @@ let { modelValue } = $defineModel<{
}>() }>()
const colorMode = useColorMode() const colorMode = useColorMode()
const userSettings = useUserSettings()
function toggleVisible() { function toggleVisible() {
modelValue = !modelValue modelValue = !modelValue
} }
@ -82,6 +84,20 @@ onBeforeUnmount(() => {
<span class="i-ri:sun-line dark:i-ri:moon-line flex-shrink-0 text-xl me-4 !align-middle" /> <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') }} {{ colorMode.value === 'light' ? $t('menu.toggle_theme.dark') : $t('menu.toggle_theme.light') }}
</button> </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="userSettings.zenMode = !userSettings.zenMode"
>
<span :class="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> </div>
</div> </div>

View file

@ -2,6 +2,8 @@
const buildInfo = useRuntimeConfig().public.buildInfo const buildInfo = useRuntimeConfig().public.buildInfo
const timeAgoOptions = useTimeAgoOptions() const timeAgoOptions = useTimeAgoOptions()
const userSettings = useUserSettings()
const buildTimeDate = new Date(buildInfo.time) const buildTimeDate = new Date(buildInfo.time)
const buildTimeAgo = useTimeAgo(buildTimeDate, timeAgoOptions) const buildTimeAgo = useTimeAgo(buildTimeDate, timeAgoOptions)
@ -26,6 +28,16 @@ function toggleDark() {
@click="userSettings.zenMode = !userSettings.zenMode" @click="userSettings.zenMode = !userSettings.zenMode"
/> />
</CommonTooltip> </CommonTooltip>
<CommonTooltip :content="$t('settings.about.sponsor_action')">
<NuxtLink
flex
text-lg
i-ri-heart-3-line hover="i-ri-heart-3-fill text-rose"
:aria-label="$t('settings.about.sponsor_action')"
href="https://github.com/sponsors/elk-zone"
target="_blank"
/>
</CommonTooltip>
</div> </div>
<div> <div>
<i18n-t v-if="isHydrated" keypath="nav.built_at"> <i18n-t v-if="isHydrated" keypath="nav.built_at">
@ -35,8 +47,15 @@ function toggleDark() {
{{ $t('nav.built_at', [$d(buildTimeDate, 'shortDate')]) }} {{ $t('nav.built_at', [$d(buildTimeDate, 'shortDate')]) }}
</span> </span>
&middot; &middot;
<!-- TODO click version to show changelog --> <NuxtLink
<span v-if="buildInfo.env === 'release'">v{{ buildInfo.version }}</span> v-if="buildInfo.env === 'release'"
external
:href="`https://github.com/elk-zone/elk/releases/tag/v${buildInfo.version}`"
target="_blank"
font-mono
>
v{{ buildInfo.version }}
</NuxtLink>
<span v-else>{{ buildInfo.env }}</span> <span v-else>{{ buildInfo.env }}</span>
<template v-if="buildInfo.commit && buildInfo.branch !== 'release'"> <template v-if="buildInfo.commit && buildInfo.branch !== 'release'">
&middot; &middot;
@ -63,7 +82,7 @@ function toggleDark() {
Discord Discord
</NuxtLink> </NuxtLink>
&middot; &middot;
<NuxtLink href="https://github.com/elk-zone" target="_blank" external> <NuxtLink href="https://github.com/elk-zone/elk" target="_blank" external>
GitHub GitHub
</NuxtLink> </NuxtLink>
</div> </div>

View file

@ -0,0 +1,37 @@
<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"
>
<mask id="a" width="240" height="234" x="4" y="1" maskUnits="userSpaceOnUse" style="mask-type:alpha">
<path
fill="#D9D9D9"
d="M244 123c0 64.617-38.383 112-103 112-64.617 0-103-30.883-103-95.5C38 111.194-8.729 36.236 8 16 29.46-9.959 88.689 6 125 6c64.617 0 119 52.383 119 117Z"
/>
</mask>
<g mask="url(#a)">
<path
class="body"
d="M116.94 88.1c-13.344 1.552-20.436-2.019-24.706 10.71 0 0 14.336 21.655 52.54 21.112-2.135 8.848-1.144 15.368-1.144 23.207 0 26.079-20.589 48.821-65.961 48.821-23.03 0-51.015 4.191-72.367 15.911-15.175 8.305-27.048 20.336-32.302 37.023l5.956 8.461 11.4.155v47.889l-13.91 21.966L-19.556 387h13.192l1.144-51.227c6.558-3.881 21.58-13.971 34.391-29.494 17.386-20.879 30.731-51.227 15.022-88.793l11.744-5.045c12.887 30.814 8.388 57.514-2.898 79.013 21.58-.698 40.11-2.095 55.819-4.734l-3.584-43.698 12.659-1.087L129.98 387h13.116l2.212-94.459c10.447-4.502 34.239-21.034 45.372-78.47 1.372-6.986 2.135-12.885 2.516-17.93 1.754-12.806 2.745-27.243 3.051-43.698l-18.683-5.976h57.42l5.567-12.807c-5.414.233-11.896-2.639-11.896-2.639l1.297-6.209H242l-65.199-34.384c-7.244 2.794-14.87 6.442-20.208 10.866-4.27-3.105-19.063-12.807-39.653-13.195Z"
/>
<path
class="wood"
d="M6.217 24.493 18.494 21c5.948 21.577 13.345 33.375 22.648 39.352 8.388 5.099 19.75 5.239 31.799 4.579-3.508-1.164-6.787-2.794-9.837-5.045-6.787-5.045-12.582-13.428-16.929-28.64l12.201-3.649c3.279 11.488 7.092 18.085 12.201 21.888 5.11 3.726 11.286 4.657 18.606 5.433 13.726 1.553 30.884 2.174 52.312 12.264 2.898 1.086 5.872 2.483 8.769 4.036-.381-.776-.762-1.553-1.296-2.406-3.66-5.822-10.828-11.953-24.097-16.92l4.27-12.109c21.581 7.917 30.121 19.171 33.553 28.097 3.965 10.168 1.525 18.124 1.525 18.124-3.05 1.009-6.1 2.406-9.608 3.492-6.634-4.579-12.887-8.033-18.835-10.75-21.962-8.304-43.466-2.638-62.53-.853-14.336 1.32-27.452.698-38.814-6.598-11.21-7.14-21.047-20.8-28.215-46.802Z"
/>
<path
class="wood"
d="M90.098 45.294c-2.516-5.744-4.041-12.807-3.355-21.5l12.659.932c-.763 10.555 2.897 17.696 7.015 22.353-5.338-.931-10.447-1.04-16.319-1.785ZM170.167 43.974l8.312-9.702c21.58 19.094 8.159 46.415 8.159 46.415l-11.819-1.32c-.382-6.24-1.144-17.836-6.635-24.371 3.584 1.84 6.635 3.865 9.99 6.908 0-5.666-1.754-12.341-8.007-17.93Z"
/>
</g>
</svg>
</span>
</template>
<style scoped>
svg path.wood {
fill: var(--c-dark-primary-light);
}
svg path.body {
fill: var(--c-primary);
}
</style>

View file

@ -16,7 +16,7 @@ const { notifications } = useNotifications()
<NavSideItem :text="$t('nav.notifications')" to="/notifications" icon="i-ri:notification-4-line" user-only :command="command"> <NavSideItem :text="$t('nav.notifications')" to="/notifications" icon="i-ri:notification-4-line" user-only :command="command">
<template #icon> <template #icon>
<div flex relative> <div flex relative>
<div class="i-ri:notification-4-line" md:text-size-inherit text-xl /> <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> <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 : '•' }} {{ notifications < 10 ? notifications : '•' }}
</div> </div>
@ -29,9 +29,9 @@ const { notifications } = useNotifications()
<NavSideItem :text="$t('action.compose')" to="/compose" icon="i-ri:quill-pen-line" user-only :command="command" /> <NavSideItem :text="$t('action.compose')" to="/compose" icon="i-ri:quill-pen-line" user-only :command="command" />
<div shrink hidden sm:block mt-4 /> <div shrink hidden sm:block mt-4 />
<NavSideItem :text="$t('nav.explore')" :to="`/${currentServer}/explore`" icon="i-ri:hashtag" :command="command" /> <NavSideItem :text="$t('nav.explore')" :to="isHydrated ? `/${currentServer}/explore` : '/explore'" icon="i-ri:hashtag" :command="command" />
<NavSideItem :text="$t('nav.local')" :to="`/${currentServer}/public/local`" icon="i-ri:group-2-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="`/${currentServer}/public`" icon="i-ri:earth-line" :command="command" /> <NavSideItem :text="$t('nav.federated')" :to="isHydrated ? `/${currentServer}/public` : '/public'" icon="i-ri:earth-line" :command="command" />
<div shrink hidden sm:block mt-4 /> <div shrink hidden sm:block mt-4 />
<NavSideItem :text="$t('nav.settings')" to="/settings" icon="i-ri:settings-3-line" :command="command" /> <NavSideItem :text="$t('nav.settings')" to="/settings" icon="i-ri:settings-3-line" :command="command" />

View file

@ -29,7 +29,7 @@ useCommand({
}) })
let activeClass = $ref('text-primary') let activeClass = $ref('text-primary')
onMastoInit(async () => { onHydrated(async () => {
// TODO: force NuxtLink to reevaluate, we now we are in this route though, so we should force it to active // 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 // we don't have currentServer defined until later
activeClass = '' activeClass = ''
@ -39,8 +39,8 @@ onMastoInit(async () => {
// Optimize rendering for the common case of being logged in, only show visual feedback for disabled user-only items // 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. // when we know there is no user.
const noUserDisable = computed(() => !isMastoInitialised.value || (props.userOnly && !currentUser.value)) const noUserDisable = computed(() => !isHydrated.value || (props.userOnly && !currentUser.value))
const noUserVisual = computed(() => isMastoInitialised.value && props.userOnly && !currentUser.value) const noUserVisual = computed(() => isHydrated.value && props.userOnly && !currentUser.value)
</script> </script>
<template> <template>
@ -66,7 +66,7 @@ const noUserVisual = computed(() => isMastoInitialised.value && props.userOnly &
<div :class="icon" text-xl /> <div :class="icon" text-xl />
</slot> </slot>
<slot> <slot>
<span block sm:hidden xl:block>{{ text }}</span> <span block sm:hidden xl:block select-none>{{ isHydrated ? text : '&nbsp;' }}</span>
</slot> </slot>
</div> </div>
</CommonTooltip> </CommonTooltip>

View file

@ -1,5 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
const { env } = useBuildInfo() const { env } = useBuildInfo()
const router = useRouter()
const back = ref<any>('')
onMounted(() => {
back.value = router.options.history.state.back
})
router.afterEach(() => {
back.value = router.options.history.state.back
})
</script> </script>
<template> <template>
@ -9,16 +17,20 @@ const { env } = useBuildInfo()
flex items-end gap-4 flex items-end gap-4
py2 px-5 py2 px-5
text-2xl text-2xl
select-none
focus-visible:ring="2 current" focus-visible:ring="2 current"
to="/" to="/"
external external
> >
<img :alt="$t('app_logo')" src="/logo.svg" shrink-0 aspect="1/1" sm:h-8 xl:h-10 class="rtl-flip"> <NavLogo shrink-0 aspect="1/1" sm:h-8 xl:h-10 class="rtl-flip" />
<div hidden xl:block> <div hidden xl:block>
{{ $t('app_name') }} <sup text-sm italic text-secondary mt-1>{{ env === 'release' ? 'alpha' : env }}</sup> {{ $t('app_name') }} <sup text-sm italic text-secondary mt-1>{{ env === 'release' ? 'alpha' : env }}</sup>
</div> </div>
</NuxtLink> </NuxtLink>
<div hidden xl:flex items-center me-8 mt-2> <div
hidden xl:flex items-center me-8 mt-2
:class="{ 'pointer-events-none op40': !back || back === '/', 'xl:flex': $route.name !== 'tag' }"
>
<NuxtLink <NuxtLink
:aria-label="$t('nav.back')" :aria-label="$t('nav.back')"
@click="$router.go(-1)" @click="$router.go(-1)"

View file

@ -1,5 +1,5 @@
<template> <template>
<VDropdown v-if="isMastoInitialised && currentUser" sm:hidden> <VDropdown v-if="isHydrated && currentUser" sm:hidden>
<div style="-webkit-touch-callout: none;"> <div style="-webkit-touch-callout: none;">
<AccountAvatar <AccountAvatar
ref="avatar" ref="avatar"

View file

@ -66,7 +66,10 @@ const isLegacyAccount = computed(() => !currentUser.value?.vapidKey)
:disabled="busy || isLegacyAccount" :disabled="busy || isLegacyAccount"
@click="$emit('subscribe')" @click="$emit('subscribe')"
> >
<span aria-hidden="true" :class="busy && animate ? 'i-ri:loader-2-fill animate-spin' : 'i-ri:check-line'" /> <span v-if="busy && animate" aria-hidden="true" block animate-spin preserve-3d>
<span block i-ri:loader-2-fill aria-hidden="true" />
</span>
<span v-else aria-hidden="true" block i-ri:check-line />
<span>{{ $t('settings.notifications.push_notifications.warning.enable_desktop') }}</span> <span>{{ $t('settings.notifications.push_notifications.warning.enable_desktop') }}</span>
</button> </button>
<slot name="error" /> <slot name="error" />

View file

@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
// @ts-expect-error missing types
import { DynamicScrollerItem } from 'vue-virtual-scroller'
import type { Paginator, WsEvents, mastodon } from 'masto' import type { Paginator, WsEvents, mastodon } from 'masto'
import type { GroupedAccountLike, NotificationSlot } from '~/types' import type { GroupedAccountLike, NotificationSlot } from '~/types'
@ -7,6 +9,8 @@ const { paginator, stream } = defineProps<{
stream?: Promise<WsEvents> stream?: Promise<WsEvents>
}>() }>()
const virtualScroller = false // TODO: fix flickering issue with virtual scroll
const groupCapacity = Number.MAX_VALUE // No limit const groupCapacity = Number.MAX_VALUE // No limit
// Group by type (and status when applicable) // Group by type (and status when applicable)
@ -135,14 +139,41 @@ const { formatNumber } = useHumanReadableNumber()
</script> </script>
<template> <template>
<CommonPaginator :paginator="paginator" :preprocess="preprocess" :stream="stream" :eager="3" event-type="notification"> <CommonPaginator
:paginator="paginator"
:preprocess="preprocess"
:stream="stream"
:eager="3"
:virtual-scroller="virtualScroller"
event-type="notification"
>
<template #updater="{ number, update }"> <template #updater="{ number, update }">
<button py-4 border="b base" flex="~ col" p-3 w-full text-primary font-bold @click="() => { update(); clearNotifications() }"> <button py-4 border="b base" flex="~ col" p-3 w-full text-primary font-bold @click="() => { update(); clearNotifications() }">
{{ $t('timeline.show_new_items', number, { named: { v: formatNumber(number) } }) }} {{ $t('timeline.show_new_items', number, { named: { v: formatNumber(number) } }) }}
</button> </button>
</template> </template>
<template #items="{ items }"> <template #default="{ item, active }">
<template v-for="item of items" :key="item.id"> <template v-if="virtualScroller">
<DynamicScrollerItem :item="item" :active="active" tag="div">
<NotificationGroupedFollow
v-if="item.type === 'grouped-follow'"
:items="item"
border="b base"
/>
<NotificationGroupedLikes
v-else-if="item.type === 'grouped-reblogs-and-favourites'"
:group="item"
border="b base"
/>
<NotificationCard
v-else
:notification="item"
hover:bg-active
border="b base"
/>
</DynamicScrollerItem>
</template>
<template v-else>
<NotificationGroupedFollow <NotificationGroupedFollow
v-if="item.type === 'grouped-follow'" v-if="item.type === 'grouped-follow'"
:items="item" :items="item"

View file

@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { PushSubscriptionError } from '~/composables/push-notifications/types'
defineProps<{ show?: boolean }>() defineProps<{ show?: boolean }>()
const { const {
@ -49,7 +51,10 @@ const saveSettings = async () => {
try { try {
const subscription = await updateSubscription() const subscription = await updateSubscription()
}
catch (err) {
// todo: handle error // todo: handle error
console.error(err)
} }
finally { finally {
busy = false busy = false
@ -72,8 +77,14 @@ const doSubscribe = async () => {
showSubscribeError = true showSubscribeError = true
} }
} }
catch { catch (err) {
if (err instanceof PushSubscriptionError) {
subscribeError = t(`settings.notifications.push_notifications.subscription_error.${err.code}`)
}
else {
console.error(err)
subscribeError = t('settings.notifications.push_notifications.subscription_error.request_error') subscribeError = t('settings.notifications.push_notifications.subscription_error.request_error')
}
showSubscribeError = true showSubscribeError = true
} }
finally { finally {
@ -91,6 +102,9 @@ const removeSubscription = async () => {
try { try {
await unsubscribe() await unsubscribe()
} }
catch (err) {
console.error(err)
}
finally { finally {
busy = false busy = false
animateRemoveSubscription = false animateRemoveSubscription = false
@ -133,7 +147,10 @@ onActivated(() => (busy = false))
:class="busy || !saveEnabled ? 'border-transparent' : null" :class="busy || !saveEnabled ? 'border-transparent' : null"
:disabled="busy || !saveEnabled" :disabled="busy || !saveEnabled"
> >
<span :class="busy && animateSave ? 'i-ri:loader-2-fill animate-spin' : 'i-ri:save-2-fill'" /> <span v-if="busy && animateSave" aria-hidden="true" block animate-spin preserve-3d>
<span block i-ri:loader-2-fill aria-hidden="true" />
</span>
<span v-else block aria-hidden="true" i-ri:save-2-fill />
{{ $t('settings.notifications.push_notifications.save_settings') }} {{ $t('settings.notifications.push_notifications.save_settings') }}
</button> </button>
<button <button
@ -143,7 +160,7 @@ onActivated(() => (busy = false))
:disabled="busy || !saveEnabled" :disabled="busy || !saveEnabled"
@click="undoChanges" @click="undoChanges"
> >
<span aria-hidden="true" class="i-material-symbols:undo-rounded" /> <span aria-hidden="true" class="block i-material-symbols:undo-rounded" />
{{ $t('settings.notifications.push_notifications.undo_settings') }} {{ $t('settings.notifications.push_notifications.undo_settings') }}
</button> </button>
</div> </div>
@ -155,7 +172,10 @@ onActivated(() => (busy = false))
:class="busy ? 'border-transparent' : null" :class="busy ? 'border-transparent' : null"
:disabled="busy" :disabled="busy"
> >
<span aria-hidden="true" :class="busy && animateRemoveSubscription ? 'i-ri:loader-2-fill animate-spin' : 'i-material-symbols:cancel-rounded'" /> <span v-if="busy && animateRemoveSubscription" aria-hidden="true" block animate-spin preserve-3d>
<span block i-ri:loader-2-fill aria-hidden="true" />
</span>
<span v-else block aria-hidden="true" i-material-symbols:cancel-rounded />
{{ $t('settings.notifications.push_notifications.unsubscribe') }} {{ $t('settings.notifications.push_notifications.unsubscribe') }}
</button> </button>
</form> </form>

View file

@ -1,6 +1,6 @@
<script setup> <script setup>
const disabled = computed(() => !isMastoInitialised.value || !currentUser.value) const disabled = computed(() => !isHydrated.value || !currentUser.value)
const disabledVisual = computed(() => isMastoInitialised.value && !currentUser.value) const disabledVisual = computed(() => isHydrated.value && !currentUser.value)
</script> </script>
<template> <template>

View file

@ -44,7 +44,7 @@ const hideEmojiPicker = () => {
</script> </script>
<template> <template>
<CommonTooltip content="Add emojis"> <CommonTooltip :content="$t('tooltip.add_emojis')">
<VDropdown <VDropdown
auto-boundary-max-size auto-boundary-max-size
@apply-show="openEmojiPicker()" @apply-show="openEmojiPicker()"

View file

@ -0,0 +1,23 @@
<script setup lang="ts">
defineProps<{
describedBy: string
}>()
defineOptions({
inheritAttrs: false,
})
</script>
<template>
<div
role="alert"
:aria-describedby="describedBy"
flex="~ col"
gap-1 text-sm
pt-1 ps-2 pe-1 pb-2
text-red-600 dark:text-red-400
border="~ base rounded red-600 dark:red-400"
>
<slot />
</div>
</template>

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { EditorContent } from '@tiptap/vue-3' import { EditorContent } from '@tiptap/vue-3'
import stringLength from 'string-length'
import type { mastodon } from 'masto' import type { mastodon } from 'masto'
import type { Ref } from 'vue'
import type { Draft } from '~/types' import type { Draft } from '~/types'
const { const {
@ -32,9 +32,10 @@ const { draft } = $(draftState)
const { const {
isExceedingAttachmentLimit, isUploading, failedAttachments, isOverDropZone, isExceedingAttachmentLimit, isUploading, failedAttachments, isOverDropZone,
uploadAttachments, pickAttachments, setDescription, removeAttachment, uploadAttachments, pickAttachments, setDescription, removeAttachment,
dropZoneRef,
} = $(useUploadMediaAttachment($$(draft))) } = $(useUploadMediaAttachment($$(draft)))
let { shouldExpanded, isExpanded, isSending, isPublishDisabled, publishDraft } = $(usePublish( let { shouldExpanded, isExpanded, isSending, isPublishDisabled, publishDraft, failedMessages } = $(usePublish(
{ {
draftState, draftState,
...$$({ expanded, isUploading, initialDraft: initial }), ...$$({ expanded, isUploading, initialDraft: initial }),
@ -61,7 +62,19 @@ const { editor } = useTiptap({
}, },
onPaste: handlePaste, onPaste: handlePaste,
}) })
const characterCount = $computed(() => htmlToText(editor.value?.getHTML() || '').length) const characterCount = $computed(() => {
let length = stringLength(htmlToText(editor.value?.getHTML() || ''))
if (draft.mentions) {
// + 1 is needed as mentions always need a space seperator at the end
length += draft.mentions.map((mention) => {
const [handle] = mention.split('@')
return `@${handle}`
}).join(' ').length + 1
}
return length
})
async function handlePaste(evt: ClipboardEvent) { async function handlePaste(evt: ClipboardEvent) {
const files = evt.clipboardData?.files const files = evt.clipboardData?.files
@ -89,6 +102,19 @@ async function publish() {
emit('published', status) emit('published', status)
} }
useWebShareTarget(async ({ data: { data, action } }: any) => {
if (action !== 'compose-with-shared-data')
return
editor.value?.commands.focus('end')
if (data.text !== undefined)
editor.value?.commands.insertContent(data.text)
if (data.files !== undefined)
await uploadAttachments(data.files)
})
defineExpose({ defineExpose({
focusEditor: () => { focusEditor: () => {
editor.value?.commands?.focus?.() editor.value?.commands?.focus?.()
@ -97,7 +123,7 @@ defineExpose({
</script> </script>
<template> <template>
<div v-if="isMastoInitialised && currentUser" flex="~ col gap-4" py3 px2 sm:px4> <div v-if="isHydrated && currentUser" flex="~ col gap-4" py3 px2 sm:px4>
<template v-if="draft.editingStatus"> <template v-if="draft.editingStatus">
<div flex="~ col gap-1"> <div flex="~ col gap-1">
<div id="state-editing" text-secondary self-center> <div id="state-editing" text-secondary self-center>
@ -119,6 +145,12 @@ defineExpose({
border="2 dashed transparent" border="2 dashed transparent"
:class="[isSending ? 'pointer-events-none' : '', isOverDropZone ? '!border-primary' : '']" :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)">
{{ acctToShortHandle(m) }}
</button>
</ContentMentionGroup>
<div v-if="draft.params.sensitive"> <div v-if="draft.params.sensitive">
<input <input
v-model="draft.params.spoilerText" v-model="draft.params.spoilerText"
@ -129,6 +161,29 @@ defineExpose({
> >
</div> </div>
<PublishErrMessage v-if="failedMessages.length > 0" described-by="publish-failed">
<head 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>
</head>
<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>
</PublishErrMessage>
<div relative flex-1 flex flex-col> <div relative flex-1 flex flex-col>
<EditorContent <EditorContent
:editor="editor" :editor="editor"
@ -138,18 +193,14 @@ defineExpose({
</div> </div>
<div v-if="isUploading" flex gap-1 items-center text-sm p1 text-primary> <div v-if="isUploading" flex gap-1 items-center text-sm p1 text-primary>
<div i-ri:loader-2-fill animate-spin /> <div animate-spin preserve-3d>
<div i-ri:loader-2-fill />
</div>
{{ $t('state.uploading') }} {{ $t('state.uploading') }}
</div> </div>
<div <PublishErrMessage
v-else-if="failedAttachments.length > 0" v-else-if="failedAttachments.length > 0"
role="alert" :described-by="isExceedingAttachmentLimit ? 'upload-failed uploads-per-post' : 'upload-failed'"
:aria-describedby="isExceedingAttachmentLimit ? 'upload-failed uploads-per-post' : 'upload-failed'"
flex="~ col"
gap-1 text-sm
pt-1 ps-2 pe-1 pb-2
text-red-600 dark:text-red-400
border="~ base rounded red-600 dark:red-400"
> >
<head id="upload-failed" flex justify-between> <head id="upload-failed" flex justify-between>
<div flex items-center gap-x-2 font-bold> <div flex items-center gap-x-2 font-bold>
@ -158,10 +209,8 @@ defineExpose({
</div> </div>
<CommonTooltip placement="bottom" :content="$t('action.clear_upload_failed')"> <CommonTooltip placement="bottom" :content="$t('action.clear_upload_failed')">
<button <button
flex rounded-4 p1 flex rounded-4 p1 hover:bg-active cursor-pointer transition-100
hover:bg-active cursor-pointer transition-100 :aria-label="$t('action.clear_upload_failed')" @click="failedAttachments = []"
:aria-label="$t('action.clear_upload_failed')"
@click="failedAttachments = []"
> >
<span aria-hidden="true" w="1.75em" h="1.75em" i-ri:close-line /> <span aria-hidden="true" w="1.75em" h="1.75em" i-ri:close-line />
</button> </button>
@ -176,7 +225,7 @@ defineExpose({
<span>{{ error[0] }}</span> <span>{{ error[0] }}</span>
</li> </li>
</ol> </ol>
</div> </PublishErrMessage>
<div v-if="draft.attachments.length" flex="~ col gap-2" overflow-auto> <div v-if="draft.attachments.length" flex="~ col gap-2" overflow-auto>
<PublishAttachment <PublishAttachment
@ -192,7 +241,7 @@ defineExpose({
<div flex gap-4> <div flex gap-4>
<div w-12 h-full sm:block hidden /> <div w-12 h-full sm:block hidden />
<div <div
v-if="shouldExpanded" flex="~ gap-1 1 wrap" m="s--1" pt-2 justify="between" max-w-full v-if="shouldExpanded" flex="~ gap-1 1 wrap" m="s--1" pt-2 justify="end" max-w-full
border="t base" border="t base"
> >
<PublishEmojiPicker <PublishEmojiPicker
@ -258,7 +307,18 @@ defineExpose({
</template> </template>
</PublishVisibilityPicker> </PublishVisibilityPicker>
<CommonTooltip id="publish-tooltip" placement="top" :content="$t('tooltip.add_publishable_content')" :disabled="!isPublishDisabled"> <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">
<button <button
btn-solid rounded-3 text-sm w-full flex="~ gap1" items-center btn-solid rounded-3 text-sm w-full flex="~ gap1" items-center
md:w-fit md:w-fit
@ -267,7 +327,12 @@ defineExpose({
aria-describedby="publish-tooltip" aria-describedby="publish-tooltip"
@click="publish" @click="publish"
> >
<div v-if="isSending" i-ri:loader-2-fill animate-spin /> <span v-if="isSending" 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>
<span v-if="draft.editingStatus">{{ $t('action.save_changes') }}</span> <span v-if="draft.editingStatus">{{ $t('action.save_changes') }}</span>
<span v-else-if="draft.params.inReplyToId">{{ $t('action.reply') }}</span> <span v-else-if="draft.params.inReplyToId">{{ $t('action.reply') }}</span>
<span v-else>{{ !isSending ? $t('action.publish') : $t('state.publishing') }}</span> <span v-else>{{ !isSending ? $t('action.publish') : $t('state.publishing') }}</span>

View file

@ -24,7 +24,7 @@ onMounted(() => {
<template> <template>
<div flex="~ col" pt-6 h-screen> <div flex="~ col" pt-6 h-screen>
<div text-right h-8> <div inline-flex justify-end h-8>
<VDropdown v-if="nonEmptyDrafts.length" placement="bottom-end"> <VDropdown v-if="nonEmptyDrafts.length" placement="bottom-end">
<button btn-text flex="inline center"> <button btn-text flex="inline center">
{{ $t('compose.drafts', nonEmptyDrafts.length, { named: { v: formatNumber(nonEmptyDrafts.length) } }) }}&#160;<div aria-hidden="true" i-ri:arrow-down-s-line /> {{ $t('compose.drafts', nonEmptyDrafts.length, { named: { v: formatNumber(nonEmptyDrafts.length) } }) }}&#160;<div aria-hidden="true" i-ri:arrow-down-s-line />

View file

@ -1,7 +1,7 @@
<template> <template>
<button <button
v-if="$pwa?.needRefresh" v-if="$pwa?.needRefresh"
bg="fade" relative rounded bg="primary-fade" relative rounded
flex="~ gap-1 center" px3 py1 text-primary flex="~ gap-1 center" px3 py1 text-primary
@click="$pwa.updateServiceWorker()" @click="$pwa.updateServiceWorker()"
> >

View file

@ -1,7 +1,7 @@
<template> <template>
<div <div
v-if="$pwa?.needRefresh" v-if="$pwa?.needRefresh"
m-2 p5 bg="fade" relative m-2 p5 bg="primary-fade" relative
rounded-lg of-hidden rounded-lg of-hidden
flex="~ col gap-3" flex="~ col gap-3"
> >

View file

@ -0,0 +1,22 @@
<script setup lang="ts">
export interface SearchEmoji {
title: string
src: string
}
defineProps<{
emoji: SearchEmoji
}>()
</script>
<template>
<div flex="~ gap3" items-center text-base>
<img
width="20"
height="20"
:src="emoji.src"
loading="lazy"
>
<span shrink overflow-hidden leading-none text-base><span text-secondary>:</span>{{ emoji.title }}<span text-secondary>:</span></span>
</div>
</template>

View file

@ -37,17 +37,19 @@ const shift = (delta: number) => index.value = (index.value + delta % results.va
const activate = () => { const activate = () => {
const currentIndex = index.value const currentIndex = index.value
index.value = -1
if (query.value.length === 0) if (query.value.length === 0)
return return
(document.activeElement as HTMLElement).blur() // Disable redirection until search page is implemented
if (currentIndex === -1) {
// Disable until search page is implemented index.value = 0
if (currentIndex === -1)
// router.push(`/search?q=${query.value}`) // router.push(`/search?q=${query.value}`)
return return
}
(document.activeElement as HTMLElement).blur()
index.value = -1
router.push(results.value[currentIndex].to) router.push(results.value[currentIndex].to)
} }
@ -66,7 +68,7 @@ const activate = () => {
bg-transparent bg-transparent
outline="focus:none" outline="focus:none"
pe-4 pe-4
:placeholder="t('nav.search')" :placeholder="isHydrated ? t('nav.search') : ''"
pb="1px" pb="1px"
placeholder-text-secondary placeholder-text-secondary
@keydown.down.prevent="shift(1)" @keydown.down.prevent="shift(1)"

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ColorMode } from '~/types' import type { ColorMode } from '~/composables/settings'
const colorMode = useColorMode() const colorMode = useColorMode()
@ -9,24 +9,33 @@ function setColorMode(mode: ColorMode) {
</script> </script>
<template> <template>
<div flex="~ gap4" w-full> <div flex="~ gap4 wrap" w-full>
<button <button
btn-text flex-1 flex="~ gap-1 center" p4 border="~ base rounded" bg-base btn-text flex-1 flex="~ gap-1 center" p4 border="~ base rounded" bg-base ws-nowrap
:tabindex="colorMode.value === 'dark' ? 0 : -1" :tabindex="colorMode.preference === 'dark' ? 0 : -1"
:class="colorMode.value === 'dark' ? 'pointer-events-none' : 'filter-saturate-0'" :class="colorMode.preference === 'dark' ? 'pointer-events-none' : 'filter-saturate-0'"
@click="setColorMode('dark')" @click="setColorMode('dark')"
> >
<div i-ri:moon-line /> <div i-ri:moon-line />
{{ $t('settings.interface.dark_mode') }} {{ $t('settings.interface.dark_mode') }}
</button> </button>
<button <button
btn-text flex-1 flex="~ gap-1 center" p4 border="~ base rounded" bg-base btn-text flex-1 flex="~ gap-1 center" p4 border="~ base rounded" bg-base ws-nowrap
:tabindex="colorMode.value === 'light' ? 0 : -1" :tabindex="colorMode.preference === 'light' ? 0 : -1"
:class="colorMode.value === 'light' ? 'pointer-events-none' : 'filter-saturate-0'" :class="colorMode.preference === 'light' ? 'pointer-events-none' : 'filter-saturate-0'"
@click="setColorMode('light')" @click="setColorMode('light')"
> >
<div i-ri:sun-line /> <div i-ri:sun-line />
{{ $t('settings.interface.light_mode') }} {{ $t('settings.interface.light_mode') }}
</button> </button>
<button
btn-text flex-1 flex="~ gap-1 center" p4 border="~ base rounded" bg-base ws-nowrap
:tabindex="colorMode.preference === 'system' ? 0 : -1"
:class="colorMode.preference === 'system' ? 'pointer-events-none' : 'filter-saturate-0'"
@click="setColorMode('system')"
>
<div i-ri:computer-line />
{{ $t('settings.interface.system_mode') }}
</button>
</div> </div>
</template> </template>

View file

@ -1,14 +1,15 @@
<script lang="ts" setup> <script lang="ts" setup>
import { DEFAULT_FONT_SIZE } from '~/constants' import { DEFAULT_FONT_SIZE } from '~/constants'
import type { FontSize } from '~/types' import type { FontSize } from '~/composables/settings'
const userSettings = useUserSettings()
const sizes = ['xs', 'sm', 'md', 'lg', 'xl'] as FontSize[] const sizes = ['xs', 'sm', 'md', 'lg', 'xl'] as FontSize[]
const fontSize = useFontSizeRef()
</script> </script>
<template> <template>
<select v-model="fontSize"> <select v-model="userSettings.fontSize">
<option v-for="size in sizes" :key="size" :value="size" :selected="fontSize === size"> <option v-for="size in sizes" :key="size" :value="size" :selected="userSettings.fontSize === size">
{{ `${$t(`settings.interface.size_label.${size}`)}${size === DEFAULT_FONT_SIZE ? $t('settings.interface.default') : ''}` }} {{ `${$t(`settings.interface.size_label.${size}`)}${size === DEFAULT_FONT_SIZE ? $t('settings.interface.default') : ''}` }}
</option> </option>
</select> </select>

View file

@ -62,7 +62,7 @@ useCommand({
/> />
</slot> </slot>
</div> </div>
<div space-y-1> <div flex="~ col gap-0.5">
<p> <p>
<slot> <slot>
<span>{{ text }}</span> <span>{{ text }}</span>

View file

@ -2,13 +2,14 @@
import type { ComputedRef } from 'vue' import type { ComputedRef } from 'vue'
import type { LocaleObject } from '#i18n' import type { LocaleObject } from '#i18n'
const { locale, setLocale } = useI18n() const userSettings = useUserSettings()
const { locales } = useI18n() as { locales: ComputedRef<LocaleObject[]> } const { locales } = useI18n() as { locales: ComputedRef<LocaleObject[]> }
</script> </script>
<template> <template>
<select :value="locale" @input="e => setLocale((e.target as any).value)"> <select v-model="userSettings.language">
<option v-for="item in locales" :key="item.code" :value="item.code" :selected="locale === item.code"> <option v-for="item in locales" :key="item.code" :value="item.code" :selected="userSettings.language === item.code">
{{ item.name }} {{ item.name }}
</option> </option>
</select> </select>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,29 @@
<script setup lang="ts">
import type { ThemeColors } from '~/composables/settings'
const themes = await import('~/constants/themes.json').then(r => r.default) as [string, ThemeColors][]
const settings = $(useUserSettings())
const currentTheme = $computed(() => settings.themeColors?.['--theme-color-name'] || themes[0][0])
function updateTheme(theme: ThemeColors) {
settings.themeColors = theme
}
</script>
<template>
<div flex="~ gap4 wrap" p2>
<button
v-for="[key, theme] in themes" :key="key"
:style="{
'background': key,
'--local-ring-color': key,
}"
:class="currentTheme === key ? 'ring-2' : 'scale-90'"
:title="key"
w-8 h-8 rounded-full transition-all
ring="$local-ring-color offset-3 offset-$c-bg-base"
@click="updateTheme(theme)"
/>
</div>
</template>

View file

@ -14,6 +14,6 @@ const { account, link = true } = defineProps<{
text-link-rounded text-link-rounded
> >
<AccountDisplayName :account="account" font-bold line-clamp-1 ws-pre-wrap break-all /> <AccountDisplayName :account="account" font-bold line-clamp-1 ws-pre-wrap break-all />
<AccountHandle :account="account" /> <AccountHandle :account="account" class="zen-none" />
</NuxtLink> </NuxtLink>
</template> </template>

View file

@ -49,7 +49,7 @@ useCommand({
<component <component
:is="as" :is="as"
v-bind="$attrs" ref="el" v-bind="$attrs" ref="el"
w-fit flex gap-1 items-center w-fit flex gap-1 items-center transition-all
rounded group rounded group
:hover=" !disabled ? hover : undefined" :hover=" !disabled ? hover : undefined"
focus:outline-none focus:outline-none

View file

@ -11,6 +11,8 @@ const focusEditor = inject<typeof noop>('focus-editor', noop)
const { details, command } = $(props) const { details, command } = $(props)
const userSettings = useUserSettings()
const { const {
status, status,
isLoading, isLoading,
@ -31,7 +33,7 @@ const reply = () => {
</script> </script>
<template> <template>
<div flex justify-between> <div flex justify-between items-center class="status-actions">
<div flex-1> <div flex-1>
<StatusActionButton <StatusActionButton
:content="$t('action.reply')" :content="$t('action.reply')"
@ -53,7 +55,7 @@ const reply = () => {
<div flex-1> <div flex-1>
<StatusActionButton <StatusActionButton
:content="$t('action.boost')" :content="$t('action.boost')"
:text="!getWellnessSetting('hideBoostCount') && status.reblogsCount ? status.reblogsCount : ''" :text="!getPreferences(userSettings, 'hideBoostCount') && status.reblogsCount ? status.reblogsCount : ''"
color="text-green" hover="text-green" group-hover="bg-green/10" color="text-green" hover="text-green" group-hover="bg-green/10"
icon="i-ri:repeat-line" icon="i-ri:repeat-line"
active-icon="i-ri:repeat-fill" active-icon="i-ri:repeat-fill"
@ -62,7 +64,7 @@ const reply = () => {
:command="command" :command="command"
@click="toggleReblog()" @click="toggleReblog()"
> >
<template v-if="status.reblogsCount && !getWellnessSetting('hideBoostCount')" #text> <template v-if="status.reblogsCount && !getPreferences(userSettings, 'hideBoostCount')" #text>
<CommonLocalizedNumber <CommonLocalizedNumber
keypath="action.boost_count" keypath="action.boost_count"
:count="status.reblogsCount" :count="status.reblogsCount"
@ -74,7 +76,7 @@ const reply = () => {
<div flex-1> <div flex-1>
<StatusActionButton <StatusActionButton
:content="$t('action.favourite')" :content="$t('action.favourite')"
:text="!getWellnessSetting('hideFavoriteCount') && status.favouritesCount ? status.favouritesCount : ''" :text="!getPreferences(userSettings, 'hideFavoriteCount') && status.favouritesCount ? status.favouritesCount : ''"
color="text-rose" hover="text-rose" group-hover="bg-rose/10" color="text-rose" hover="text-rose" group-hover="bg-rose/10"
icon="i-ri:heart-3-line" icon="i-ri:heart-3-line"
active-icon="i-ri:heart-3-fill" active-icon="i-ri:heart-3-fill"
@ -83,7 +85,7 @@ const reply = () => {
:command="command" :command="command"
@click="toggleFavourite()" @click="toggleFavourite()"
> >
<template v-if="status.favouritesCount && !getWellnessSetting('hideFavoriteCount')" #text> <template v-if="status.favouritesCount && !getPreferences(userSettings, 'hideFavoriteCount')" #text>
<CommonLocalizedNumber <CommonLocalizedNumber
keypath="action.favourite_count" keypath="action.favourite_count"
:count="status.favouritesCount" :count="status.favouritesCount"

View file

@ -23,22 +23,11 @@ const clipboard = useClipboard()
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const { t } = useI18n() const { t } = useI18n()
const userSettings = useUserSettings()
const isAuthor = $computed(() => status.account.id === currentUser.value?.account.id) const isAuthor = $computed(() => status.account.id === currentUser.value?.account.id)
const { const { client } = $(useMasto())
toggle: _toggleTranslation,
translation,
enabled: isTranslationEnabled,
} = useTranslation(props.status)
const toggleTranslation = async () => {
isLoading.translation = true
await _toggleTranslation()
isLoading.translation = false
}
const masto = useMasto()
const getPermalinkUrl = (status: mastodon.v1.Status) => { const getPermalinkUrl = (status: mastodon.v1.Status) => {
const url = getStatusPermalinkRoute(status) const url = getStatusPermalinkRoute(status)
@ -53,6 +42,12 @@ const copyLink = async (status: mastodon.v1.Status) => {
await clipboard.copy(url) await clipboard.copy(url)
} }
const copyOriginalLink = async (status: mastodon.v1.Status) => {
const url = status.url
if (url)
await clipboard.copy(url)
}
const { share, isSupported: isShareSupported } = useShare() const { share, isSupported: isShareSupported } = useShare()
const shareLink = async (status: mastodon.v1.Status) => { const shareLink = async (status: mastodon.v1.Status) => {
const url = getPermalinkUrl(status) const url = getPermalinkUrl(status)
@ -62,14 +57,14 @@ const shareLink = async (status: mastodon.v1.Status) => {
const deleteStatus = async () => { const deleteStatus = async () => {
if (await openConfirmDialog({ if (await openConfirmDialog({
title: t('menu.delete_confirm.title'), title: t('confirm.delete_posts.title'),
confirm: t('menu.delete_confirm.confirm'), confirm: t('confirm.delete_posts.confirm'),
cancel: t('menu.delete_confirm.cancel'), cancel: t('confirm.delete_posts.cancel'),
}) !== 'confirm') }) !== 'confirm')
return return
removeCachedStatus(status.id) removeCachedStatus(status.id)
await masto.v1.statuses.remove(status.id) await client.v1.statuses.remove(status.id)
if (route.name === 'status') if (route.name === 'status')
router.back() router.back()
@ -87,7 +82,7 @@ const deleteAndRedraft = async () => {
} }
removeCachedStatus(status.id) removeCachedStatus(status.id)
await masto.v1.statuses.remove(status.id) await client.v1.statuses.remove(status.id)
await openPublishDialog('dialog', await getDraftFromStatus(status), true) await openPublishDialog('dialog', await getDraftFromStatus(status), true)
// Go to the new status, if the page is the old status // Go to the new status, if the page is the old status
@ -121,9 +116,9 @@ const showFavoritedAndBoostedBy = () => {
<CommonDropdown flex-none ms3 placement="bottom" :eager-mount="command"> <CommonDropdown flex-none ms3 placement="bottom" :eager-mount="command">
<StatusActionButton <StatusActionButton
:content="$t('action.more')" :content="$t('action.more')"
color="text-purple" color="text-primary"
hover="text-purple" hover="text-primary"
group-hover="bg-purple/10" group-hover="bg-primary-light"
icon="i-ri:more-line" icon="i-ri:more-line"
my--2 my--2
/> />
@ -180,6 +175,13 @@ const showFavoritedAndBoostedBy = () => {
@click="copyLink(status)" @click="copyLink(status)"
/> />
<CommonDropdownItem
:text="$t('menu.copy_original_link_to_post')"
icon="i-ri:links-fill"
:command="command"
@click="copyOriginalLink(status)"
/>
<CommonDropdownItem <CommonDropdownItem
v-if="isShareSupported" v-if="isShareSupported"
:text="$t('menu.share_post')" :text="$t('menu.share_post')"
@ -205,15 +207,7 @@ const showFavoritedAndBoostedBy = () => {
/> />
</NuxtLink> </NuxtLink>
<CommonDropdownItem <template v-if="isHydrated && currentUser">
v-if="isTranslationEnabled && status.language !== languageCode"
:text="translation.visible ? $t('menu.show_untranslated') : $t('menu.translate_post')"
icon="i-ri:translate"
:command="command"
@click="toggleTranslation"
/>
<template v-if="isMastoInitialised && currentUser">
<template v-if="isAuthor"> <template v-if="isAuthor">
<CommonDropdownItem <CommonDropdownItem
:text="status.pinned ? $t('menu.unpin_on_profile') : $t('menu.pin_on_profile')" :text="status.pinned ? $t('menu.unpin_on_profile') : $t('menu.pin_on_profile')"

View file

@ -30,7 +30,7 @@ const aspectRatio = computed(() => {
if (fullSize) if (fullSize)
return rawAspectRatio.value return rawAspectRatio.value
if (rawAspectRatio.value) if (rawAspectRatio.value)
return clamp(rawAspectRatio.value, 0.8, 2.5) return clamp(rawAspectRatio.value, 0.8, 6)
return undefined return undefined
}) })
@ -188,7 +188,7 @@ useIntersectionObserver(video, (entries) => {
{{ $t('status.img_alt.dismiss') }} {{ $t('status.img_alt.dismiss') }}
</button> </button>
</div> </div>
<p> <p whitespace-pre-wrap>
{{ attachment.description }} {{ attachment.description }}
</p> </p>
</div> </div>

View file

@ -3,13 +3,15 @@ import type { mastodon } from 'masto'
const { const {
status, status,
newer,
withAction = true, withAction = true,
} = defineProps<{ } = defineProps<{
status: mastodon.v1.Status | mastodon.v1.StatusEdit status: mastodon.v1.Status | mastodon.v1.StatusEdit
newer?: mastodon.v1.Status
withAction?: boolean withAction?: boolean
}>() }>()
const { translation } = useTranslation(status) const { translation } = useTranslation(status, getLanguageCode())
const emojisObject = useEmojisFallback(() => status.emojis) const emojisObject = useEmojisFallback(() => status.emojis)
const vnode = $computed(() => { const vnode = $computed(() => {
@ -17,14 +19,18 @@ const vnode = $computed(() => {
return null return null
const vnode = contentToVNode(status.content, { const vnode = contentToVNode(status.content, {
emojis: emojisObject.value, emojis: emojisObject.value,
mentions: 'mentions' in status ? status.mentions : undefined,
markdown: true, markdown: true,
collapseMentionLink: !!('inReplyToId' in status && status.inReplyToId),
status: 'id' in status ? status : undefined,
inReplyToStatus: newer,
}) })
return vnode return vnode
}) })
</script> </script>
<template> <template>
<div class="status-body" whitespace-pre-wrap break-words :class="{ 'with-action': withAction }"> <div class="status-body" whitespace-pre-wrap break-words :class="{ 'with-action': withAction }" relative>
<span <span
v-if="status.content" v-if="status.content"
class="content-rich line-compact" dir="auto" class="content-rich line-compact" dir="auto"
@ -35,7 +41,10 @@ const vnode = $computed(() => {
<div v-else /> <div v-else />
<template v-if="translation.visible"> <template v-if="translation.visible">
<div my2 h-px border="b base" bg-base /> <div my2 h-px border="b base" bg-base />
<ContentRich class="line-compact" :content="translation.text" :emojis="status.emojis" /> <ContentRich v-if="translation.success" class="line-compact" :content="translation.text" :emojis="status.emojis" />
<div v-else text-red-4>
Error: {{ translation.error }}
</div>
</template> </template>
</div> </div>
</template> </template>

View file

@ -23,6 +23,8 @@ const props = withDefaults(
{ actions: true }, { actions: true },
) )
const userSettings = useUserSettings()
const status = $computed(() => { const status = $computed(() => {
if (props.status.reblog && !props.status.content) if (props.status.reblog && !props.status.content)
return props.status.reblog return props.status.reblog
@ -124,7 +126,7 @@ const showReplyTo = $computed(() => !replyToMain && !directReply)
p="t-1 b-0.5 x-1px" p="t-1 b-0.5 x-1px"
relative text-secondary ws-nowrap relative text-secondary ws-nowrap
> >
<div i-ri:repeat-fill me-46px text-green w-16px h-16px /> <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> <div absolute top-1 ms-24px w-32px h-32px rounded-full>
<AccountHoverWrapper :account="rebloggedBy"> <AccountHoverWrapper :account="rebloggedBy">
<NuxtLink :to="getAccountRoute(rebloggedBy)"> <NuxtLink :to="getAccountRoute(rebloggedBy)">
@ -182,7 +184,7 @@ const showReplyTo = $computed(() => !replyToMain && !directReply)
</div> </div>
<!-- Content --> <!-- Content -->
<StatusContent :status="status" :context="context" mb2 :class="{ 'mt-2 mb1': isDM }" /> <StatusContent :status="status" :newer="newer" :context="context" mb2 :class="{ 'mt-2 mb1': isDM }" />
<StatusActions v-if="actions !== false" v-show="!userSettings.zenMode" :status="status" /> <StatusActions v-if="actions !== false" v-show="!userSettings.zenMode" :status="status" />
</div> </div>
</div> </div>

View file

@ -3,6 +3,7 @@ import type { mastodon } from 'masto'
const { status, context } = defineProps<{ const { status, context } = defineProps<{
status: mastodon.v1.Status status: mastodon.v1.Status
newer?: mastodon.v1.Status
context?: mastodon.v2.FilterContext | 'details' context?: mastodon.v2.FilterContext | 'details'
}>() }>()
@ -25,7 +26,7 @@ const isFiltered = $computed(() => filterPhrase && (context && context !== 'deta
'ms--3.5 mt--1 ms--1': isDM && context !== 'details', 'ms--3.5 mt--1 ms--1': isDM && context !== 'details',
}" }"
> >
<StatusBody v-if="!isFiltered && status.sensitive && !status.spoilerText" :status="status" :with-action="!isDetails" :class="isDetails ? 'text-xl' : ''" /> <StatusBody v-if="!isFiltered && status.sensitive && !status.spoilerText" :status="status" :newer="newer" :with-action="!isDetails" :class="isDetails ? 'text-xl' : ''" />
<StatusSpoiler :enabled="status.sensitive || isFiltered" :filter="isFiltered"> <StatusSpoiler :enabled="status.sensitive || isFiltered" :filter="isFiltered">
<template v-if="filterPhrase" #spoiler> <template v-if="filterPhrase" #spoiler>
<p>{{ `${$t('status.filter_hidden_phrase')}: ${filterPhrase}` }}</p> <p>{{ `${$t('status.filter_hidden_phrase')}: ${filterPhrase}` }}</p>
@ -33,7 +34,8 @@ const isFiltered = $computed(() => filterPhrase && (context && context !== 'deta
<template v-else-if="status.spoilerText" #spoiler> <template v-else-if="status.spoilerText" #spoiler>
<p>{{ status.spoilerText }}</p> <p>{{ status.spoilerText }}</p>
</template> </template>
<StatusBody v-if="!status.sensitive || status.spoilerText" :status="status" :with-action="!isDetails" :class="isDetails ? 'text-xl' : ''" /> <StatusBody v-if="!status.sensitive || status.spoilerText" :status="status" :newer="newer" :with-action="!isDetails" :class="isDetails ? 'text-xl' : ''" />
<StatusTranslation :status="status" />
<StatusPoll v-if="status.poll" :status="status" /> <StatusPoll v-if="status.poll" :status="status" />
<StatusMedia <StatusMedia
v-if="status.mediaAttachments?.length" v-if="status.mediaAttachments?.length"

View file

@ -3,12 +3,15 @@ import type { mastodon } from 'masto'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
status: mastodon.v1.Status status: mastodon.v1.Status
newer?: mastodon.v1.Status
command?: boolean command?: boolean
actions?: boolean actions?: boolean
}>(), { }>(), {
actions: true, actions: true,
}) })
const userSettings = useUserSettings()
const status = $computed(() => { const status = $computed(() => {
if (props.status.reblog && props.status.reblog) if (props.status.reblog && props.status.reblog)
return props.status.reblog return props.status.reblog
@ -20,7 +23,7 @@ const createdAt = useFormattedDateTime(status.createdAt)
const { t } = useI18n() const { t } = useI18n()
useHeadFixed({ useHeadFixed({
title: () => `${status.account.displayName || status.account.acct} ${t('common.in')} ${t('app_name')}: "${removeHTMLTags(status.content) || ''}"`, title: () => `${getDisplayName(status.account)} ${t('common.in')} ${t('app_name')}: "${removeHTMLTags(status.content) || ''}"`,
}) })
const isDM = $computed(() => status.visibility === 'direct') const isDM = $computed(() => status.visibility === 'direct')
@ -34,7 +37,7 @@ const isDM = $computed(() => status.visibility === 'direct')
<AccountInfo :account="status.account" /> <AccountInfo :account="status.account" />
</AccountHoverWrapper> </AccountHoverWrapper>
</NuxtLink> </NuxtLink>
<StatusContent :status="status" context="details" /> <StatusContent :status="status" :newer="newer" context="details" />
<div flex="~ gap-1" items-center text-secondary text-sm> <div flex="~ gap-1" items-center text-secondary text-sm>
<div flex> <div flex>
<div>{{ createdAt }}</div> <div>{{ createdAt }}</div>
@ -59,7 +62,7 @@ const isDM = $computed(() => status.visibility === 'direct')
{{ status.application?.name }} {{ status.application?.name }}
</div> </div>
</div> </div>
<div border="t base" pt-2> <div border="t base" py-2>
<StatusActions v-if="actions" :status="status" details :command="command" /> <StatusActions v-if="actions" :status="status" details :command="command" />
</div> </div>
</div> </div>

View file

@ -3,8 +3,10 @@ import { favouritedBoostedByStatusId } from '~/composables/dialog'
const type = ref<'favourited-by' | 'boosted-by'>('favourited-by') const type = ref<'favourited-by' | 'boosted-by'>('favourited-by')
const { client } = $(useMasto())
function load() { function load() {
return useMasto().v1.statuses[type.value === 'favourited-by' ? 'listFavouritedBy' : 'listRebloggedBy'](favouritedBoostedByStatusId.value!) return client.v1.statuses[type.value === 'favourited-by' ? 'listFavouritedBy' : 'listRebloggedBy'](favouritedBoostedByStatusId.value!)
} }
const paginator = $computed(() => load()) const paginator = $computed(() => load())

View file

@ -15,7 +15,8 @@ const expiredTimeAgo = useTimeAgo(poll.expiresAt!, timeAgoOptions)
const expiredTimeFormatted = useFormattedDateTime(poll.expiresAt!) const expiredTimeFormatted = useFormattedDateTime(poll.expiresAt!)
const { formatPercentage } = useHumanReadableNumber() const { formatPercentage } = useHumanReadableNumber()
const masto = useMasto() const { client } = $(useMasto())
async function vote(e: Event) { async function vote(e: Event) {
const formData = new FormData(e.target as HTMLFormElement) const formData = new FormData(e.target as HTMLFormElement)
const choices = formData.getAll('choices') as string[] const choices = formData.getAll('choices') as string[]
@ -27,28 +28,37 @@ async function vote(e: Event) {
} }
poll.voted = true poll.voted = true
poll.votesCount++ poll.votesCount++
if (!poll.votersCount && poll.votesCount)
poll.votesCount = poll.votesCount + 1
else
poll.votersCount = (poll.votersCount || 0) + 1 poll.votersCount = (poll.votersCount || 0) + 1
cacheStatus({ ...status, poll }, undefined, true) cacheStatus({ ...status, poll }, undefined, true)
await masto.v1.polls.vote(poll.id, { choices }) await client.v1.polls.vote(poll.id, { choices })
} }
const votersCount = $computed(() => poll.votersCount ?? 0) const votersCount = $computed(() => poll.votersCount ?? poll.votesCount ?? 0)
</script> </script>
<template> <template>
<div flex flex-col w-full items-stretch gap-3 dir="auto"> <div flex flex-col w-full items-stretch gap-2 py3 dir="auto" class="poll-wrapper">
<form v-if="!poll.voted && !poll.expired" flex flex-col gap-4 accent-primary @click.stop="noop" @submit.prevent="vote"> <form v-if="!poll.voted && !poll.expired" flex="~ col gap3" accent-primary @click.stop="noop" @submit.prevent="vote">
<label v-for="(option, index) of poll.options" :key="index" flex items-center gap-2 px-2> <label v-for="(option, index) of poll.options" :key="index" flex="~ gap2" items-center>
<input name="choices" :value="index" :type="poll.multiple ? 'checkbox' : 'radio'"> <input name="choices" :value="index" :type="poll.multiple ? 'checkbox' : 'radio'" cursor-pointer>
{{ option.title }} {{ option.title }}
</label> </label>
<button btn-solid> <button btn-solid mt-1>
{{ $t('action.vote') }} {{ $t('action.vote') }}
</button> </button>
</form> </form>
<template v-else> <template v-else>
<div v-for="(option, index) of poll.options" :key="index" py-1 relative :style="{ '--bar-width': toPercentage((option.votesCount || 0) / poll.votesCount) }"> <div
v-for="(option, index) of poll.options"
:key="index" py-1 relative
:style="{ '--bar-width': toPercentage((option.votesCount || 0) / poll.votesCount) }"
>
<div flex justify-between pb-2 w-full> <div flex justify-between pb-2 w-full>
<span inline-flex align-items> <span inline-flex align-items>
{{ option.title }} {{ option.title }}
@ -61,7 +71,7 @@ const votersCount = $computed(() => poll.votersCount ?? 0)
</div> </div>
</div> </div>
</template> </template>
<div text-sm flex="~ inline" gap-x-1> <div text-sm flex="~ inline" gap-x-1 text-secondary>
<CommonLocalizedNumber <CommonLocalizedNumber
keypath="status.poll.count" keypath="status.poll.count"
:count="poll.votesCount" :count="poll.votesCount"

View file

@ -9,74 +9,13 @@ const props = defineProps<{
root?: boolean root?: boolean
}>() }>()
// mastodon's default max og image width
const ogImageWidth = 400
const alt = $computed(() => `${props.card.title} - ${props.card.title}`)
const isSquare = $computed(() => (
props.smallPictureOnly
|| props.card.width === props.card.height
|| Number(props.card.width || 0) < ogImageWidth
|| Number(props.card.height || 0) < ogImageWidth / 2
))
const providerName = $computed(() => props.card.providerName ? props.card.providerName : new URL(props.card.url).hostname) const providerName = $computed(() => props.card.providerName ? props.card.providerName : new URL(props.card.url).hostname)
const gitHubCards = $(useFeatureFlag('experimentalGitHubCards')) const gitHubCards = $(usePreferences('experimentalGitHubCards'))
// 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',
}
</script> </script>
<template> <template>
<StatusPreviewGitHub v-if="gitHubCards && providerName === 'GitHub'" :card="card" /> <LazyStatusPreviewGitHub v-if="gitHubCards && providerName === 'GitHub'" :card="card" />
<NuxtLink <LazyStatusPreviewStackBlitz v-else-if="gitHubCards && providerName === 'stackblitz.com'" :card="card" :small-picture-only="smallPictureOnly" :root="root" />
v-else <StatusPreviewCardNormal v-else :card="card" :small-picture-only="smallPictureOnly" :root="root" />
block
of-hidden
:to="card.url"
bg-card
hover:bg-active
:class="{
'flex': isSquare,
'p-4': root,
'rounded-lg': !root,
}"
target="_blank"
external
>
<div
v-if="card.image"
flex flex-col
display-block of-hidden
:class="{
'sm:(min-w-32 w-32 h-32) min-w-22 w-22 h-22': isSquare,
'w-full aspect-[1.91]': !isSquare,
'rounded-lg': root,
}"
>
<CommonBlurhash
:blurhash="card.blurhash"
:src="card.image"
:width="card.width"
:height="card.height"
:alt="alt"
w-full h-full object-cover
/>
</div>
<div
v-else
min-w-22 w-22 h-22 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>
<StatusPreviewCardInfo :root="root" :card="card" :provider="providerName" />
</NuxtLink>
</template> </template>

View file

@ -12,12 +12,12 @@ defineProps<{
<template> <template>
<div <div
p4 max-h-2xl max-h-2xl
flex flex-col flex flex-col
my-auto
:class="[ :class="[
root ? 'flex-gap-1' : 'justify-center sm:justify-start', root ? 'flex-gap-1' : 'justify-center sm:justify-start',
]" ]"
my-auto
> >
<p text-secondary ws-pre-wrap break-all line-clamp-1> <p text-secondary ws-pre-wrap break-all line-clamp-1>
{{ provider }} {{ provider }}

View file

@ -0,0 +1,78 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
const props = 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(() => `${props.card.title} - ${props.card.title}`)
const isSquare = $computed(() => (
props.smallPictureOnly
|| props.card.width === props.card.height
|| Number(props.card.width || 0) < ogImageWidth
|| Number(props.card.height || 0) < ogImageWidth / 2
))
const providerName = $computed(() => props.card.providerName ? props.card.providerName : new URL(props.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',
}
</script>
<template>
<NuxtLink
block
of-hidden
:to="card.url"
bg-card
hover:bg-active
:class="{
'flex': isSquare,
'p-4': root,
'rounded-lg': !root,
}"
target="_blank"
external
>
<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,
}"
>
<CommonBlurhash
:blurhash="card.blurhash"
:src="card.image"
:width="card.width"
:height="card.height"
:alt="alt"
w-full h-full object-cover
/>
</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>
<StatusPreviewCardInfo :p="isSquare ? 'x-4' : '4'" :root="root" :card="card" :provider="providerName" />
</NuxtLink>
</template>

View file

@ -0,0 +1,96 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
const props = 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
}>()
interface Meta {
code?: string
file?: string
lines?: string
project?: string
}
// Protect against long code snippets
const maxLines = 20
const meta = $computed(() => {
const { description } = props.card
const meta = description.match(/.+\n\nCode Snippet from (.+), lines ([\w-]+)\n\n(.+)/s)
const file = meta?.[1]
const lines = meta?.[2].replaceAll('N', '')
const code = meta?.[3].split('\n').slice(0, maxLines).join('\n')
const project = props.card.title?.replace(' - StackBlitz', '')
const info = $ref<Meta>({
file,
lines,
code,
project,
})
return info
})
const vnodeCode = $computed(() => {
if (!meta.code)
return null
const vnode = contentToVNode(`<p>\`\`\`${meta.file?.split('.')?.[1] ?? ''}\n${meta.code}\n\`\`\`\</p>`, {
markdown: true,
})
return vnode
})
</script>
<template>
<div
v-if="meta.code"
flex flex-col gap-1
display-block of-hidden
w-full
rounded-lg
overflow-hidden
pb-2
>
<div whitespace-pre-wrap break-words>
<span v-if="vnodeCode" class="content-rich line-compact" dir="auto">
<component :is="vnodeCode" />
</span>
</div>
<div
flex
justify-between
display-block of-hidden
bg-card
w-full
p-3
pb-4
>
<div flex flex-col>
<p flex gap-1>
<span>{{ $t('custom_cards.stackblitz.snippet_from', [meta.file]) }}</span><span text-secondary>{{ `- ${$t('custom_cards.stackblitz.lines', [meta.lines])}` }}</span>
</p>
<div flex font-bold gap-2>
<span text-primary>{{ meta.project }}</span><span flex text-secondary><span flex items-center><svg h-5 width="22.27" height="32" viewBox="0 0 256 368"><path fill="currentColor" d="M109.586 217.013H0L200.34 0l-53.926 150.233H256L55.645 367.246l53.927-150.233z" /></svg></span><span>StackBlitz</span></span>
</div>
</div>
<NuxtLink external target="_blank" btn-solid pt-0 pb-1 px-2 h-fit :to="card.url">
{{ $t('custom_cards.stackblitz.open') }}
</NuxtLink>
</div>
</div>
<StatusPreviewCardNormal v-else :card="card" :small-picture-only="smallPictureOnly" :root="root" />
</template>
<style scoped>
.content-rich p {
margin-top: 0;
}
.code-block {
margin-top: 0;
border-radius: 0;
}
</style>

View file

@ -0,0 +1,41 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
const { status } = defineProps<{
status: mastodon.v1.Status
}>()
const {
toggle: _toggleTranslation,
translation,
enabled: isTranslationEnabled,
} = useTranslation(status, getLanguageCode())
let translating = $ref(false)
const toggleTranslation = async () => {
translating = true
try {
await _toggleTranslation()
}
finally {
translating = false
}
}
</script>
<template>
<div>
<button
v-if="isTranslationEnabled && status.language !== getLanguageCode()" p-0 flex="~ center" gap-2 text-sm
:disabled="translating" disabled-bg-transparent btn-text class="disabled-text-$c-text-btn-disabled-deeper" @click="toggleTranslation"
>
<span v-if="translating" block animate-spin preserve-3d>
<span block i-ri:loader-2-fill />
</span>
<div v-else i-ri:translate />
{{ translation.visible ? $t('menu.show_untranslated') : $t('menu.translate_post') }}
</button>
</div>
</template>
<style scoped></style>

View file

@ -6,7 +6,7 @@ const { status } = defineProps<{
status: mastodon.v1.Status status: mastodon.v1.Status
}>() }>()
const paginator = useMasto().v1.statuses.listHistory(status.id) const paginator = useMastoClient().v1.statuses.listHistory(status.id)
const showHistory = (edit: mastodon.v1.StatusEdit) => { const showHistory = (edit: mastodon.v1.StatusEdit) => {
openEditHistoryDialog(edit) openEditHistoryDialog(edit)

View file

@ -9,13 +9,13 @@ const emit = defineEmits<{
(event: 'change'): void (event: 'change'): void
}>() }>()
const masto = useMasto() const { client } = $(useMasto())
const toggleFollowTag = async () => { const toggleFollowTag = async () => {
if (tag.following) if (tag.following)
await masto.v1.tags.unfollow(tag.name) await client.v1.tags.unfollow(tag.name)
else else
await masto.v1.tags.follow(tag.name) await client.v1.tags.follow(tag.name)
emit('change') emit('change')
} }

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
const paginator = useMasto().v1.blocks.list() const paginator = useMastoClient().v1.blocks.list()
</script> </script>
<template> <template>

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
const paginator = useMasto().v1.bookmarks.list() const paginator = useMastoClient().v1.bookmarks.list()
</script> </script>
<template> <template>

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
const paginator = useMasto().v1.conversations.list() const paginator = useMastoClient().v1.conversations.list()
</script> </script>
<template> <template>

View file

@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
const masto = useMasto() const { client } = $(useMasto())
const paginator = masto.v1.domainBlocks.list() const paginator = client.v1.domainBlocks.list()
const unblock = async (domain: string) => { const unblock = async (domain: string) => {
await masto.v1.domainBlocks.unblock(domain) await client.v1.domainBlocks.unblock(domain)
} }
</script> </script>

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
const paginator = useMasto().v1.favourites.list() const paginator = useMastoClient().v1.favourites.list()
</script> </script>
<template> <template>

View file

@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
const paginator = useMasto().v1.timelines.listHome({ limit: 30 }) const paginator = useMastoClient().v1.timelines.listHome({ limit: 30 })
const stream = useMasto().v1.stream.streamUser() const stream = $(useStreaming(client => client.v1.stream.streamUser()))
onBeforeUnmount(() => stream?.then(s => s.disconnect()))
</script> </script>
<template> <template>

View file

@ -1,11 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
// Default limit is 20 notifications, and servers are normally caped to 30 // Default limit is 20 notifications, and servers are normally caped to 30
const paginator = useMasto().v1.notifications.list({ limit: 30, types: ['mention'] }) const paginator = useMastoClient().v1.notifications.list({ limit: 30, types: ['mention'] })
const stream = $(useStreaming(client => client.v1.stream.streamUser()))
const { clearNotifications } = useNotifications() const { clearNotifications } = useNotifications()
onActivated(clearNotifications) onActivated(clearNotifications)
const stream = useMasto().v1.stream.streamUser()
</script> </script>
<template> <template>

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
const paginator = useMasto().v1.mutes.list() const paginator = useMastoClient().v1.mutes.list()
</script> </script>
<template> <template>

View file

@ -1,11 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
// Default limit is 20 notifications, and servers are normally caped to 30 // Default limit is 20 notifications, and servers are normally caped to 30
const paginator = useMasto().v1.notifications.list({ limit: 30 }) const paginator = useMastoClient().v1.notifications.list({ limit: 30 })
const stream = useStreaming(client => client.v1.stream.streamUser())
const { clearNotifications } = useNotifications() const { clearNotifications } = useNotifications()
onActivated(clearNotifications) onActivated(clearNotifications)
const stream = useMasto().v1.stream.streamUser()
</script> </script>
<template> <template>

View file

@ -14,7 +14,7 @@ const { paginator, stream, account, buffer = 10 } = defineProps<{
}>() }>()
const { formatNumber } = useHumanReadableNumber() const { formatNumber } = useHumanReadableNumber()
const virtualScroller = $(useFeatureFlag('experimentalVirtualScroller')) const virtualScroller = $(usePreferences('experimentalVirtualScroller'))
const showOriginSite = $computed(() => const showOriginSite = $computed(() =>
account && account.id !== currentUser.value?.account.id && getServerName(account) !== currentServer.value, account && account.id !== currentUser.value?.account.id && getServerName(account) !== currentServer.value,

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
const paginator = useMasto().v1.accounts.listStatuses(currentUser.value!.account.id, { pinned: true }) const paginator = useMastoClient().v1.accounts.listStatuses(currentUser.value!.account.id, { pinned: true })
</script> </script>
<template> <template>

View file

@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
const paginator = useMasto().v1.timelines.listPublic({ limit: 30 }) const paginator = useMastoClient().v1.timelines.listPublic({ limit: 30 })
const stream = useMasto().v1.stream.streamPublicTimeline() const stream = useStreaming(client => client.v1.stream.streamPublicTimeline())
onBeforeUnmount(() => stream.then(s => s.disconnect()))
</script> </script>
<template> <template>

View file

@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
const paginator = useMasto().v1.timelines.listPublic({ limit: 30, local: true }) const paginator = useMastoClient().v1.timelines.listPublic({ limit: 30, local: true })
const stream = useMasto().v1.stream.streamCommunityTimeline() const stream = useStreaming(client => client.v1.stream.streamCommunityTimeline())
onBeforeUnmount(() => stream.then(s => s.disconnect()))
</script> </script>
<template> <template>

View file

@ -0,0 +1,97 @@
<script setup lang="ts">
import { getEmojiMatchesInText } from '@iconify/utils/lib/emoji/replace/find'
import CommonScrollIntoView from '../common/CommonScrollIntoView.vue'
import type { CustomEmoji, Emoji } from '~~/composables/tiptap/suggestion'
import { isCustomEmoji } from '~~/composables/tiptap/suggestion'
import { emojiFilename, emojiPrefix, emojiRegEx } from '~~/config/emojis'
const { items, command } = defineProps<{
items: (CustomEmoji | Emoji)[]
command: Function
isPending?: boolean
}>()
const emojis = computed(() => {
return items.map((item: CustomEmoji | Emoji) => {
if (isCustomEmoji(item)) {
return {
title: item.shortcode,
src: item.url,
emoji: item,
}
}
const skin = item.skins.find(skin => skin.native !== undefined)
const match = getEmojiMatchesInText(emojiRegEx, skin!.native)[0]
const file = emojiFilename(match)
return {
title: item.id,
src: `/emojis/${emojiPrefix}/${file.filename}`,
emoji: item,
}
})
})
let selectedIndex = $ref(0)
watch(items, () => {
selectedIndex = 0
})
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'ArrowUp') {
selectedIndex = ((selectedIndex + items.length) - 1) % items.length
return true
}
else if (event.key === 'ArrowDown') {
selectedIndex = (selectedIndex + 1) % items.length
return true
}
else if (event.key === 'Enter') {
selectItem(selectedIndex)
return true
}
return false
}
function selectItem(index: number) {
const emoji = emojis.value[index]
if (emoji)
command(emoji)
}
defineExpose({
onKeyDown,
})
</script>
<template>
<div
v-if="isPending || items.length"
relative bg-base text-base shadow border="~ base rounded"
text-sm py-2 overflow-x-hidden overflow-y-auto max-h-100
min-w-40 max-w-50
>
<template v-if="isPending">
<div flex gap-1 items-center p2 animate-pulse>
<div i-ri:loader-2-line animate-spin />
<span>Fetching...</span>
</div>
</template>
<template v-if="items.length">
<CommonScrollIntoView
v-for="(item, index) in emojis" :key="index"
:active="index === selectedIndex"
as="button"
:class="index === selectedIndex ? 'bg-active' : 'text-secondary'"
block m0 w-full text-left px2 py1
@click="selectItem(index)"
>
<SearchEmojiInfo :emoji="item" />
</CommonScrollIntoView>
</template>
</div>
<div v-else />
</template>

View file

@ -46,7 +46,9 @@ defineExpose({
<div v-if="isPending || items.length" relative bg-base text-base shadow border="~ base rounded" text-sm py-2 overflow-x-hidden overflow-y-auto max-h-100> <div v-if="isPending || items.length" relative bg-base text-base shadow border="~ base rounded" text-sm py-2 overflow-x-hidden overflow-y-auto max-h-100>
<template v-if="isPending"> <template v-if="isPending">
<div flex gap-1 items-center p2 animate-pulse> <div flex gap-1 items-center p2 animate-pulse>
<div i-ri:loader-2-line animate-spin /> <div animate-spin preserve-3d>
<div i-ri:loader-2-line />
</div>
<span>Fetching...</span> <span>Fetching...</span>
</div> </div>
</template> </template>

View file

@ -46,7 +46,9 @@ defineExpose({
<div v-if="isPending || items.length" relative bg-base text-base shadow border="~ base rounded" text-sm py-2 overflow-x-hidden overflow-y-auto max-h-100> <div v-if="isPending || items.length" relative bg-base text-base shadow border="~ base rounded" text-sm py-2 overflow-x-hidden overflow-y-auto max-h-100>
<template v-if="isPending"> <template v-if="isPending">
<div flex gap-1 items-center p2 animate-pulse> <div flex gap-1 items-center p2 animate-pulse>
<div i-ri:loader-2-line animate-spin /> <div animate-spin preserve-3d>
<div i-ri:loader-2-line />
</div>
<span>Fetching...</span> <span>Fetching...</span>
</div> </div>
</template> </template>

View file

@ -1,5 +1,9 @@
<script setup lang="ts">
const mask = useMask()
</script>
<template> <template>
<VDropdown :distance="0" placement="top-start"> <VDropdown :distance="0" placement="top-start" strategy="fixed" @apply-show="mask.show()" @apply-hide="mask.hide()">
<button btn-action-icon :aria-label="$t('action.switch_account')"> <button btn-action-icon :aria-label="$t('action.switch_account')">
<div :class="{ 'hidden xl:block': currentUser }" i-ri:more-2-line /> <div :class="{ 'hidden xl:block': currentUser }" i-ri:more-2-line />
<AccountAvatar v-if="currentUser" xl:hidden :account="currentUser.account" w-9 h-9 square /> <AccountAvatar v-if="currentUser" xl:hidden :account="currentUser.account" w-9 h-9 square />

View file

@ -2,14 +2,13 @@
import type { UserLogin } from '~/types' import type { UserLogin } from '~/types'
const all = useUsers() const all = useUsers()
const router = useRouter() const router = useRouter()
const masto = useMasto()
const switchUser = (user: UserLogin) => { const clickUser = (user: UserLogin) => {
if (user.account.id === currentUser.value?.account.id) if (user.account.id === currentUser.value?.account.id)
router.push(getAccountRoute(user.account)) router.push(getAccountRoute(user.account))
else else
masto.loginTo(user) switchUser(user)
} }
</script> </script>
@ -17,16 +16,27 @@ const switchUser = (user: UserLogin) => {
<div flex justify-start items-end px-2 gap-5> <div flex justify-start items-end px-2 gap-5>
<div flex="~ wrap-reverse" gap-5> <div flex="~ wrap-reverse" gap-5>
<template v-for="user of all" :key="user.id"> <template v-for="user of all" :key="user.id">
<CommonTooltip :distance="8" :delay="{ show: 300, hide: 100 }">
<button <button
flex rounded flex rounded
cursor-pointer cursor-pointer
aria-label="Switch user" aria-label="Switch user"
:class="user.account.id === currentUser?.account.id ? '' : 'op25 grayscale'" :class="user.account.id === currentUser?.account.id ? '' : 'op25 grayscale'"
hover="filter-none op100" hover="filter-none op100"
@click="switchUser(user)" @click="clickUser(user)"
> >
<AccountAvatar w-13 h-13 :account="user.account" square /> <AccountAvatar w-13 h-13 :account="user.account" square />
</button> </button>
<template #popper>
<div text-center>
<span text-4>
<AccountDisplayName :account="user.account" />
</span>
<AccountHandle :account="user.account" />
</div>
</template>
</CommonTooltip>
</template> </template>
</div> </div>
<div flex items-center justify-center w-13 h-13> <div flex items-center justify-center w-13 h-13>

Some files were not shown because too many files have changed in this diff Show more