Merge branch 'main' into feat/387-expand-mastodon-links

This commit is contained in:
Ayo 2023-01-03 09:27:33 +01:00
commit 4890ea40d0
254 changed files with 8486 additions and 2739 deletions

View file

@ -10,3 +10,7 @@ NUXT_STORAGE_DRIVER=
NUXT_STORAGE_FS_BASE=
NUXT_PUBLIC_DISABLE_VERSION_CHECK=
NUXT_GITHUB_CLIENT_ID=
NUXT_GITHUB_CLIENT_SECRET=
NUXT_GITHUB_INVITE_TOKEN=

View file

@ -1 +1 @@
MOCK_USER='{"user":{"server":"universeodon.com","token":"yZcpj0FmnsEkUvBiXSCb_KQnccl2IU0kx9TfDbcxPJY","vapidKey":"BJwtUVlyCabpMnLI6HOyu-qMfJswxEq_c8pgRymxjTN_vCzMWfGrRHrwNczj9LIokAHtxh6Ziw1Kq7_ERDoriz0=","account":{"id":"109424142224653388","username":"elkdev","acct":"elkdev@universeodon.com","displayName":"Elk Dev Team","locked":false,"bot":false,"discoverable":null,"group":false,"createdAt":"2022-11-28T00:00:00.000Z","note":"","url":"https://universeodon.com/@elkdev","avatar":"https://universeodon.com/avatars/original/missing.png","avatarStatic":"https://universeodon.com/avatars/original/missing.png","header":"https://universeodon.com/headers/original/missing.png","headerStatic":"https://universeodon.com/headers/original/missing.png","followersCount":3,"followingCount":4,"statusesCount":20,"lastStatusAt":"2022-12-13","noindex":false,"source":{"privacy":"public","sensitive":false,"language":null,"note":"","fields":[],"followRequestsCount":0},"emojis":[],"fields":[],"role":{"id":"-99","name":"","permissions":"65536","color":"","highlighted":false}}},"server":{"109424142224653388":{"uri":"universeodon.com","title":"Universeodon","shortDescription":"Be one with the fediverse.","description":"","email":"novae@universeodon.com","version":"4.0.2","urls":{"streamingApi":"wss://universeodon.com"},"stats":{"userCount":57026,"statusCount":283364,"domainCount":11515},"thumbnail":"https://media.universeodon.com/site_uploads/files/000/000/003/@1x/9de6fc1bbd150b05.png","languages":["en"],"registrations":true,"approvalRequired":false,"invitesEnabled":true,"configuration":{"accounts":{"maxFeaturedTags":10},"statuses":{"maxCharacters":500,"maxMediaAttachments":4,"charactersReservedPerUrl":23},"mediaAttachments":{"supportedMimeTypes":["image/jpeg","image/png","image/gif","image/heic","image/heif","image/webp","image/avif","video/webm","video/mp4","video/quicktime","video/ogg","audio/wave","audio/wav","audio/x-wav","audio/x-pn-wave","audio/vnd.wave","audio/ogg","audio/vorbis","audio/mpeg","audio/mp3","audio/webm","audio/flac","audio/aac","audio/m4a","audio/x-m4a","audio/mp4","audio/3gpp","video/x-ms-asf"],"imageSizeLimit":10485760,"imageMatrixLimit":16777216,"videoSizeLimit":41943040,"videoFrameRateLimit":60,"videoMatrixLimit":2304000},"polls":{"maxOptions":4,"maxCharactersPerOption":50,"minExpiration":300,"maxExpiration":2629746}},"contactAccount":{"id":"109287809647205395","username":"supernovae","acct":"supernovae","displayName":"Supernovae","locked":false,"bot":false,"discoverable":true,"group":false,"createdAt":"2022-11-04T00:00:00.000Z","note":"","url":"https://universeodon.com/@supernovae","avatar":"https://media.universeodon.com/accounts/avatars/109/287/809/647/205/395/original/551eafba585d19e5.jpg","avatarStatic":"https://media.universeodon.com/accounts/avatars/109/287/809/647/205/395/original/551eafba585d19e5.jpg","header":"https://media.universeodon.com/accounts/headers/109/287/809/647/205/395/original/5de388c5945925c5.jpg","headerStatic":"https://media.universeodon.com/accounts/headers/109/287/809/647/205/395/original/5de388c5945925c5.jpg","followersCount":6387,"followingCount":305,"statusesCount":1753,"lastStatusAt":"2022-11-28","noindex":false,"emojis":[],"fields":[]},"rules":[]}}}'
MOCK_USER='{"user":{"server":"universeodon.com","token":"yZcpj0FmnsEkUvBiXSCb_KQnccl2IU0kx9TfDbcxPJY","vapidKey":"BJwtUVlyCabpMnLI6HOyu-qMfJswxEq_c8pgRymxjTN_vCzMWfGrRHrwNczj9LIokAHtxh6Ziw1Kq7_ERDoriz0=","account":{"id":"109424142224653388","username":"elkdev","acct":"elkdev@universeodon.com","displayName":"Elk Dev Team","locked":false,"bot":false,"discoverable":null,"group":false,"createdAt":"2022-11-28T00:00:00.000Z","note":"","url":"https://universeodon.com/@elkdev","avatar":"https://universeodon.com/avatars/original/missing.png","avatarStatic":"https://universeodon.com/avatars/original/missing.png","header":"https://universeodon.com/headers/original/missing.png","headerStatic":"https://universeodon.com/headers/original/missing.png","followersCount":3,"followingCount":4,"statusesCount":20,"lastStatusAt":"2022-12-13","noindex":false,"source":{"privacy":"public","sensitive":false,"language":null,"note":"","fields":[],"followRequestsCount":0},"emojis":[],"fields":[],"role":{"id":"-99","name":"","permissions":"65536","color":"","highlighted":false}}},"server":{"universeodon.com":{"uri":"universeodon.com","title":"Universeodon","shortDescription":"Be one with the fediverse.","description":"","email":"novae@universeodon.com","version":"4.0.2","urls":{"streamingApi":"wss://universeodon.com"},"stats":{"userCount":57026,"statusCount":283364,"domainCount":11515},"thumbnail":"https://media.universeodon.com/site_uploads/files/000/000/003/@1x/9de6fc1bbd150b05.png","languages":["en"],"registrations":true,"approvalRequired":false,"invitesEnabled":true,"configuration":{"accounts":{"maxFeaturedTags":10},"statuses":{"maxCharacters":500,"maxMediaAttachments":4,"charactersReservedPerUrl":23},"mediaAttachments":{"supportedMimeTypes":["image/jpeg","image/png","image/gif","image/heic","image/heif","image/webp","image/avif","video/webm","video/mp4","video/quicktime","video/ogg","audio/wave","audio/wav","audio/x-wav","audio/x-pn-wave","audio/vnd.wave","audio/ogg","audio/vorbis","audio/mpeg","audio/mp3","audio/webm","audio/flac","audio/aac","audio/m4a","audio/x-m4a","audio/mp4","audio/3gpp","video/x-ms-asf"],"imageSizeLimit":10485760,"imageMatrixLimit":16777216,"videoSizeLimit":41943040,"videoFrameRateLimit":60,"videoMatrixLimit":2304000},"polls":{"maxOptions":4,"maxCharactersPerOption":50,"minExpiration":300,"maxExpiration":2629746}},"contactAccount":{"id":"109287809647205395","username":"supernovae","acct":"supernovae","displayName":"Supernovae","locked":false,"bot":false,"discoverable":true,"group":false,"createdAt":"2022-11-04T00:00:00.000Z","note":"","url":"https://universeodon.com/@supernovae","avatar":"https://media.universeodon.com/accounts/avatars/109/287/809/647/205/395/original/551eafba585d19e5.jpg","avatarStatic":"https://media.universeodon.com/accounts/avatars/109/287/809/647/205/395/original/551eafba585d19e5.jpg","header":"https://media.universeodon.com/accounts/headers/109/287/809/647/205/395/original/5de388c5945925c5.jpg","headerStatic":"https://media.universeodon.com/accounts/headers/109/287/809/647/205/395/original/5de388c5945925c5.jpg","followersCount":6387,"followingCount":305,"statusesCount":1753,"lastStatusAt":"2022-11-28","noindex":false,"emojis":[],"fields":[]},"rules":[]}}}'

1
.gitignore vendored
View file

@ -10,6 +10,7 @@ dist
.netlify/
public/shiki
public/emojis
*~
*swp

View file

@ -19,5 +19,9 @@
"i18n-ally.keystyle": "nested",
"i18n-ally.sourceLanguage": "en-US",
"i18n-ally.preferredDelimiter": "_",
"i18n-ally.sortKeys": true
"i18n-ally.sortKeys": true,
"i18n-ally.keysInUse": [
"time_ago_options.*",
"visibility.*"
]
}

147
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,147 @@
# Contributing Guide
Hi! We are really excited that you are interested in contributing to Elk. Before submitting your contribution, please make sure to take a moment and read through the following guide.
Refer also to https://github.com/antfu/contribute.
## Set up your local development environment
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).
To develop and test the Elk package:
1. Fork the Elk repository to your own GitHub account and then clone it to your local device.
2. Ensure using the latest Node.js (16.x)
3. Elk uses pnpm v7, you must enable [Corepack](https://github.com/nodejs/corepack) by running `corepack enable`.
4. Check out a branch where you can work and commit your changes:
```shell
git checkout -b my-new-branch
```
5. Run `pnpm i` in Elk's root folder
6. 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.
### 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:pwa:mocked` 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.
If not using private browsing mode, you will need to uninstall the PWA application from your browser once you finish testing:
- Open `Dev Tools` (`Option + ⌘ + J` on MacOS, `Shift + CTRL + J` on Windows/Linux)
- Go to `Application > Storage`, you should check following checkboxes:
- Application: [x] Unregister service worker
- Storage: [x] IndexedDB and [x] Local and session storage
- Cache: [x] Cache storage and [x] Application cache
- Click on `Clear site data` button
- Go to `Application > Service Workers` and check the current `service worker` is missing or has the state `deleted` or `redundant`
## 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:
- `pnpm test:unit` to run unit tests, maybe you also need to update snapshots
- `pnpm test:typecheck` to run TypeScript checks run on CI
## RTL Support
Elk supports `right-to-left` languages, we need to make sure that the UI is working correctly in both directions.
Simple approach used by most websites of relying on direction set in HTML element does not work because direction for various items, such as timeline, does not always match direction set in HTML.
We've added some `UnoCSS` utilities styles to help you with that:
- Do not use `left/right` padding and margin: for example `pl-1`. Use `padding-inline-start/end` instead. So `pl-1` should be `ps-1`, `pr-1` should be `pe-1`. Same rules applies for margin.
- Do not use `rtl-` classes, such as `rtl-left-0`.
- For icons that should be rotated for RTL, add `class="rtl-flip"`. This can only be used for icons outside of elements with `dir="auto"`, such as timeline, and is the only exception from rule above. For icons inside timeline it might not work as expected.
- For absolute positioned elements, don't use `left/right`: for example `left-0`. Use `inset-inline-start/end` instead. `UnoCSS` shortcuts are `inset-is` for `inset-inline-start` and `inset-ie` for `inset-inline-end`. Example: `left-0` should be replaced with `inset-is-0`.
- If you need to change border radius for an entire left or right side, use `border-inline-start/end`. `UnoCSS` shortcuts are `rounded-is` for left side, `rounded-ie` for right side. Example: `rounded-l-5` should be replaced with `rounded-ie-5`.
- If you need to change border radius for one corner, use `border-start-end-radius` and similar rules. `UnoCSS` shortcuts are `rounded` + top/bottom as either `-bs` (top) or `-be` (bottom) + left/right as either `-is` (left) or `-ie` (right). Example: `rounded-tl-0` should be replaced with `rounded-bs-is-0`.
## Internalization
We are using [vue-i18n](https://vue-i18n.intlify.dev/) via [nuxt-i18n](https://i18n.nuxtjs.org/) to handle internalization.
### Adding a new language
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.
3. Add the language to the `locales` array in [config/i18n.ts](../config/i18n.ts#L13)
4. If the language is `right-to-left`, add `dir` option with `rtl` value, for example, for [ar-EG](../config/i18n.ts#L63)
5. If the language requires special pluralization rules, add `pluralRule` callback option, for example, for [ar-EG](../config/i18n.ts#L64)
Check [Pluralization rule callback](https://vue-i18n.intlify.dev/guide/essentials/pluralization.html#custom-pluralization) for more info.
### Messages interpolation
Most of the messages used in Elk do not require any interpolation, however, there are some messages that require interpolation: check [Message Format Syntax](https://vue-i18n.intlify.dev/guide/essentials/syntax.html) for more info.
We're using these types of interpolation:
- [List interpolation](https://vue-i18n.intlify.dev/guide/essentials/syntax.html#list-interpolation)
- [Named interpolation](https://vue-i18n.intlify.dev/guide/essentials/syntax.html#interpolations)
- [Linked messages](https://vue-i18n.intlify.dev/guide/essentials/syntax.html#linked-messages)
- [Literal interpolation](https://vue-i18n.intlify.dev/guide/essentials/syntax.html#literal-interpolation)
#### List interpolation
You can access the elements of the list using the object notation using the index: for example, `{0}` for the first element, `{1}` for the second, `{2}` for the third and so on.
#### Named interpolation
Elk will use named interpolation only to handle plurals for number formatting. We have 2 scenarios for this:
- using `plural` **with** `i18n-t` component
- using `plural` **without** `i18n-t` component
Check [Custom Plural Number Formatting Entries](#custom-plural-number-formatting-entries) for custom plural entries in Elk with available values for interpolation.
When using plural number formatting, we'll have always `{n}` available in the message, for example, `You have {n} new notifications|You have {n} new notification|You have {n} new notifications` or `You have no new notifications|You have 1 new notification|You have {n} new notifications`.
We've included `v` named parameter, it will be used to pass the formatted number using [Intl.NumberFormat::format](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/format): will be the number with separators symbols. The exception to previous rule is when we're using `plural` **with** `i18n-t` component, in this case, we'll need to use `{0}` instead `{v}` to access the number.
Additionally, Elk will use [compact notation for numbers](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#parameters) for some entries, check `notation` and `compactDisplay` options: for example, `1K` for `1000`, `1M` for `1000000`, `1B` for `1000000000` and so on. That entry will be available in the message using `{v}` named parameter (or `{0}` if using the message **with** `i18n-t` component).
You can run this code in your browser console to see how it works:
```ts
[1, 12, 123, 1234, 12345, 123456, 1234567].forEach((n) => {
const acc = {}
Array.from(['en-US', 'en-GB', 'de-DE', 'zh-CN', 'ja-JP', 'es-ES', 'fr-FR', 'cs-CZ', 'ar-EG']).forEach((l) => {
const nf = new Intl.NumberFormat(l, {
style: 'decimal',
maximumFractionDigits: 0,
})
const nf2 = new Intl.NumberFormat(l, {
notation: 'compact',
compactDisplay: 'short',
maximumFractionDigits: 1,
})
acc[l] = {
number: n,
format: nf.format(n),
compact: nf2.format(n),
}
})
console.table(acc)
})
```
#### Custom Plural Number Formatting Entries
**Warning**:
Either **{0}**, **{v}** or **{followers}** 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:
- `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.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.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**
- `account.followers_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 use**
- `account.posts_count`: `{0}` for formatted number and `{n}` for raw number - **{0} should be use**
- `notification.followed_you_count`: `{followers}` for formatted number and `{n}` for raw number - **{followers} should be use**
- `status.poll.count`: `{0}` for formatted number and `{n}` for raw number - **{0} should be use**
- `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}`
- `timeline.show_new_items`: `{v}` for formatted number and `{n}` for raw number - **{v} should be use**

View file

@ -2,8 +2,8 @@
*A nimble Mastodon web client*
<p align="center">
<a href="https://viteconf.org" target="_blank" rel="noopener noreferrer">
<img width="180" src="https://elk.zone/logo.svg" alt="Vite logo">
<a href="https://elk.zone" target="_blank" rel="noopener noreferrer">
<img width="180" height="180" src="./elk.svg" alt="Elk logo">
</a>
</p>
<br/>
@ -13,26 +13,45 @@
</p>
<br/>
Elk is in early alpha, but it is already quite usable. We would love your feedback and contributions.
# Elk is in early alpha ⚠️
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 learn more and get involved!
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.
The client is deployed to [elk.zone](https://elk.zone), you can share screenshots on social media but avoid sharing this URL or the discord server until we open the repo.
The client is deployed to [elk.zone](https://elk.zone), 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 friedns and invite others you think could be interested in helping to improve Elk.
> **Note**
> If you would like to contribute, until the repo is open, please create branches in the main repository and send a PR from there.
## Sponsors
# Contributing
We want to thanks the generous sponsoring and help of:
Hi! We're really excited that you're interested in contributing to Elk! Before submitting your contribution, please read through the following guide.
<a href="https://nuxtlabs.com/" target="_blank" rel="noopener noreferrer" >
<img src="./images/nuxtlabs.svg" alt="NuxtLabs" height="85">
</a>
<br><br>
<a href="https://stackblitz.com/" target="_blank" rel="noopener noreferrer" >
<img src="./images/stackblitz.svg" alt="StackBlitz" height="85">
</a>
<br><br>
## Online
And all the companies and individuals sponsoring Elk Team members. If you're enjoying the app, consider sponsoring our team:
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.
- [Anthony Fu](https://github.com/sponsors/antfu)
- [Daniel Roe](https://github.com/sponsors/danielroe)
- [三咲智子 Kevin Deng](https://github.com/sponsors/sxzz)
- [Patak](https://github.com/sponsors/patak-dev)
We would also appreciate sponsoring other contributors to the Elk project. If someone helps you solve an issue or implement a feature you wanted, supporting them would help make this project and OS more sustainable.
## Contributing
We're really excited that you're interested in contributing to Elk! Before submitting your contribution, please read through the following guide.
### Online
You can use [StackBlitz CodeFlow](https://stackblitz.com/codeflow) to fix bugs or implement features. You'll also see a CodeFlow button on PRs to review them without a local setup. Once the elk repo has been cloned in CodeFlow, the dev server will start automatically and print the URL to open the App. You should receive a prompt in the bottom-right suggesting to open it in the Editor or in another Tab. To learn more, check out the [CodeFlow docs](https://developer.stackblitz.com/codeflow/what-is-codeflow).
[![Open in Codeflow](https://developer.stackblitz.com/img/open_in_codeflow.svg)](https://pr.new/elk-zone/elk)
## Local Setup
### Local Setup
Clone the repository and run on the root folder:
@ -41,6 +60,8 @@ pnpm i
pnpm run dev
```
`Warning`: you will need `corepack` enabled, check out the [Elk Contributing Guide](./CONTRIBUTING.md) for a detailed guide on how to set up the project locally.
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:
```
@ -48,7 +69,7 @@ ni
nr dev
```
## Testing
### Testing
Elk uses [Vitest](https://vitest.dev). You can run the test suite with:
@ -56,7 +77,7 @@ Elk uses [Vitest](https://vitest.dev). You can run the test suite with:
nr test
```
# Stack
## Stack
- [Vite](https://vitejs.dev/) - Next Generation Frontend Tooling
- [Nuxt](https://nuxt.com/) - The Intuitive Web Framework
@ -70,6 +91,6 @@ nr test
- [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
# License
## License
MIT
[MIT](./LICENSE) &copy; 2022-PRESENT Elk contributors

View file

@ -1,17 +1,15 @@
<script setup lang="ts">
setupI18n()
setupLogging()
setupPageHeader()
provideGlobalCommands()
// We want to trigger rerendering the page when account changes
const key = computed(() => `${currentServer.value}:${currentUser.value?.account.id || ''}`)
const key = computed(() => `${currentUser.value?.server ?? currentServer.value}:${currentUser.value?.account.id || ''}`)
</script>
<template>
<NuxtLoadingIndicator color="repeating-linear-gradient(to right,var(--c-primary) 0%,var(--c-primary-active) 100%)" />
<NuxtLayout :key="key">
<NuxtPage v-if="isMastoInitialised" />
<NuxtPage />
</NuxtLayout>
<PWAPrompt />
<AriaAnnouncer />
</template>

View file

@ -1,39 +0,0 @@
<script setup>
import { usePWA } from '~/composables/pwa'
const { close, needRefresh, updateServiceWorker } = usePWA()
</script>
<!-- TODO: remove shadow on mobile and position it above the bottom nav -->
<template>
<div
v-if="needRefresh"
role="alertdialog"
aria-labelledby="pwa-toast-title"
aria-describedby="pwa-toast-description"
animate animate-back-in-up md:animate-back-in-right
z11
fixed
bottom-14 md:bottom-0 right-0
m-2 p-4
bg-base border="~ base"
rounded
text-left
shadow
>
<h2 id="pwa-toast-title" sr-only>
{{ $t('pwa.title') }}
</h2>
<div id="pwa-toast-message">
{{ $t('pwa.message') }}
</div>
<div m-t4 flex="~ colum" gap-x="4">
<button type="button" btn-solid text-sm px-2 py-1 text-center @click="updateServiceWorker()">
{{ $t('pwa.reload') }}
</button>
<button type="button" btn-outline px-2 py-1 text-sm text-center @click="close">
{{ $t('pwa.close') }}
</button>
</div>
</div>
</template>

View file

@ -12,6 +12,8 @@ const error = $ref(false)
<template>
<img
:key="account.avatar"
width="400"
height="400"
:src="error ? 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' : account.avatar"
:alt="$t('account.avatar_description', [account.username])"
loading="lazy"

View file

@ -0,0 +1,16 @@
<script setup lang="ts">
import type { Account } from 'masto'
// Avatar with a background base achieving a 3px border to be used in status cards
// The border is used for Avatar on Avatar for reblogs and connecting replies
defineProps<{
account: Account
}>()
</script>
<template>
<div :key="account.avatar" v-bind="$attrs" rounded-full bg-base w-54px h-54px flex items-center justify-center>
<AccountAvatar :account="account" w-48px h-48px />
</div>
</template>

View file

@ -24,7 +24,7 @@ defineOptions({
<!-- User info -->
<div flex sm:flex-row flex-col flex-gap-2>
<div flex items-center justify-between>
<div w-17 h-17 rounded-full border-4 border-bg-base z-2 mt--2 ml--1>
<div w-17 h-17 rounded-full border-4 border-bg-base z-2 mt--2 ms--1>
<AccountAvatar :account="account" />
</div>
<a block sm:hidden href="javascript:;" @click.stop>
@ -43,10 +43,9 @@ defineOptions({
</div>
</div>
<!-- Note -->
<div v-if="account.note">
<div v-if="account.note" max-h-100 overflow-y-auto>
<ContentRich
:content="account.note" :emojis="account.emojis"
line-clamp-2
/>
</div>
<!-- Follow info -->

View file

@ -7,7 +7,7 @@
<!-- User info -->
<div flex sm:flex-row flex-col flex-gap-2>
<div flex items-center justify-between>
<div w-17 h-17 rounded-full border-4 border-bg-base z-2 mt--2 ml--1 of-hidden bg-base>
<div w-17 h-17 rounded-full border-4 border-bg-base z-2 mt--2 ms--1 of-hidden bg-base>
<div class="flex skeleton-loading-bg" w-full h-full />
</div>
<div block sm:hidden class="skeleton-loading-bg" h-8 w-30 rounded-full />

View file

@ -1,5 +1,5 @@
<template>
<div flex="~" items-center border="~" rounded-md px-2 text-xs>
<div flex="~" items-center border="~ base" text-secondary-light rounded-md px-1 text-xs my-auto>
{{ $t('account.bot') }}
</div>
</template>

View file

@ -11,10 +11,11 @@ const isSelf = $computed(() => currentUser.value?.account.id === account.id)
const enable = $computed(() => !isSelf && currentUser.value)
const relationship = $computed(() => props.relationship || useRelationship(account).value)
const masto = useMasto()
async function toggleFollow() {
relationship!.following = !relationship!.following
try {
const newRel = await useMasto().accounts[relationship!.following ? 'follow' : 'unfollow'](account.id)
const newRel = await masto.accounts[relationship!.following ? 'follow' : 'unfollow'](account.id)
Object.assign(relationship!, newRel)
}
catch {
@ -23,6 +24,30 @@ async function toggleFollow() {
}
}
async function unblock() {
relationship!.blocking = false
try {
const newRel = await masto.accounts.unblock(account.id)
Object.assign(relationship!, newRel)
}
catch {
// TODO error handling
relationship!.blocking = true
}
}
async function unmute() {
relationship!.muting = false
try {
const newRel = await masto.accounts.unmute(account.id)
Object.assign(relationship!, newRel)
}
catch {
// TODO error handling
relationship!.muting = true
}
}
const { t } = useI18n()
useCommand({
@ -39,6 +64,12 @@ const buttonStyle = $computed(() => {
if (!relationship)
return 'text-inverted'
if (relationship.blocking)
return 'text-inverted bg-red border-red'
if (relationship.muting)
return 'text-base bg-code border-base'
// If following, use a label style with a strong border for Mutuals
if (relationship.following)
return `text-base ${relationship.followedBy ? 'border-strong' : 'border-base'}`
@ -54,9 +85,20 @@ const buttonStyle = $computed(() => {
gap-1 items-center group
:disabled="relationship?.requested"
border-1
rounded-full flex="~ gap2 center" font-500 w-30 h-fit py1 :class="buttonStyle" :hover="relationship?.following ? 'border-red text-red' : 'bg-base border-primary text-primary'" @click="toggleFollow"
rounded-full flex="~ gap2 center" font-500 w-30 h-fit py1
:class="buttonStyle"
:hover="!relationship?.blocking && !relationship?.muting && relationship?.following ? 'border-red text-red' : 'bg-base border-primary text-primary'"
@click="relationship?.blocking ? unblock() : relationship?.muting ? unmute() : toggleFollow()"
>
<template v-if="relationship?.following">
<template v-if="relationship?.blocking">
<span group-hover="hidden">{{ $t('account.blocking') }}</span>
<span hidden group-hover="inline">{{ $t('account.unblock') }}</span>
</template>
<template v-if="relationship?.muting">
<span group-hover="hidden">{{ $t('account.muting') }}</span>
<span hidden group-hover="inline">{{ $t('account.unmute') }}</span>
</template>
<template v-else-if="relationship?.following">
<span group-hover="hidden">{{ relationship?.followedBy ? $t('account.mutuals') : $t('account.following') }}</span>
<span hidden group-hover="inline">{{ $t('account.unfollow') }}</span>
</template>

View file

@ -9,7 +9,7 @@ const serverName = $computed(() => getServerName(account))
</script>
<template>
<p line-clamp-1 whitespace-pre-wrap break-all text-secondary-light>
<p line-clamp-1 whitespace-pre-wrap break-all text-secondary-light dir="ltr">
<!-- fix: #274 only line-clamp-1 can be used here, using text-ellipsis is not valid -->
<span text-secondary>{{ getShortHandle(account) }}</span>
<span v-if="serverName" text-secondary-light>@{{ serverName }}</span>

View file

@ -17,10 +17,6 @@ const createdAt = $(useFormattedDateTime(() => account.createdAt, {
const namedFields = ref<Field[]>([])
const iconFields = ref<Field[]>([])
function getFieldNameIcon(fieldName: string) {
const name = fieldName.trim().toLowerCase()
return ACCOUNT_FIELD_ICONS[name] || undefined
}
function getFieldIconTitle(fieldName: string) {
return fieldName === 'Joined' ? t('account.joined') : fieldName
}
@ -48,7 +44,7 @@ watchEffect(() => {
const icons: Field[] = []
account.fields?.forEach((field) => {
const icon = getFieldNameIcon(field.name)
const icon = getAccountFieldIcon(field.name)
if (icon)
icons.push(field)
else
@ -62,6 +58,8 @@ watchEffect(() => {
namedFields.value = named
iconFields.value = icons
})
const isSelf = $computed(() => currentUser.value?.account.id === account.id)
</script>
<template>
@ -88,9 +86,18 @@ watchEffect(() => {
<AccountHandle :account="account" />
</div>
</div>
<div absolute top-18 right-0 flex gap-2 items-center>
<div absolute top-18 inset-ie-0 flex gap-2 items-center>
<AccountMoreButton :account="account" :command="command" />
<AccountFollowButton :account="account" :command="command" />
<!-- Edit profile -->
<NuxtLink
v-if="isSelf"
to="/settings/profile/appearance"
gap-1 items-center border="1" rounded-full flex="~ gap2 center" font-500 w-30 h-fit py1
hover="border-primary text-primary bg-active"
>
{{ $t('settings.profile.appearance.title') }}
</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 />
@ -98,8 +105,8 @@ watchEffect(() => {
</button> -->
</div>
</div>
<div v-if="account.note">
<ContentRich text-4 text-secondary :content="account.note" :emojis="account.emojis" />
<div v-if="account.note" max-h-100 overflow-y-auto>
<ContentRich text-4 text-base :content="account.note" :emojis="account.emojis" />
</div>
<div v-if="namedFields.length" flex="~ col wrap gap1">
<div v-for="field in namedFields" :key="field.name" flex="~ gap-1" items-center>
@ -111,7 +118,7 @@ watchEffect(() => {
</div>
<div v-if="iconFields.length" flex="~ wrap gap-4">
<div v-for="field in iconFields" :key="field.name" flex="~ gap-1" items-center>
<div text-secondary :class="getFieldNameIcon(field.name)" :title="getFieldIconTitle(field.name)" />
<div text-secondary :class="getAccountFieldIcon(field.name)" :title="getFieldIconTitle(field.name)" />
<ContentRich text-sm filter-saturate-0 :content="field.value" :emojis="account.emojis" />
</div>
</div>

View file

@ -11,12 +11,14 @@ const relationship = $(useRelationship(account))
<template>
<div v-show="relationship" flex="~ col gap2" rounded min-w-90 max-w-120 z-100 overflow-hidden p-4>
<div flex="~ gap2" items-center>
<NuxtLink :to="getAccountRoute(account)" flex-auto rounded-full hover:bg-active transition-100 pr5 mr-a>
<NuxtLink :to="getAccountRoute(account)" flex-auto rounded-full hover:bg-active transition-100 pe5 me-a>
<AccountInfo :account="account" />
</NuxtLink>
<AccountFollowButton text-sm :account="account" :relationship="relationship" />
</div>
<ContentRich text-4 text-secondary :content="account.note" :emojis="account.emojis" />
<div v-if="account.note" max-h-100 overflow-y-auto>
<ContentRich text-4 text-secondary :content="account.note" :emojis="account.emojis" />
</div>
<AccountPostsFollowers text-sm :account="account" />
</div>
</template>

View file

@ -14,7 +14,7 @@ defineOptions({
</script>
<template>
<VMenu v-if="!disabled && account" placement="bottom-start" :delay="{ show: 500, hide: 100 }" v-bind="$attrs">
<VMenu v-if="!disabled && account" placement="bottom-start" :delay="{ show: 500, hide: 100 }" v-bind="$attrs" :close-on-content-click="false">
<slot />
<template #popper>
<AccountHoverCard v-if="account" :account="account" />

View file

@ -16,19 +16,19 @@ defineOptions({
<!-- This is sometimes (like in the sidebar) used directly as a button, and sometimes, like in follow notifications, as a link. I think this component may need a second refactor that either lets an implementation pass in a link or an action and adapt to what's passed in, or the implementations need to be updated to wrap in the action they want to take and this be just the layout for these items -->
<template>
<component :is="as" flex gap-3 v-bind="$attrs">
<AccountHoverWrapper :disabled="!hoverCard" :account="account" shrink-0>
<AccountAvatar :account="account" w-12 h-12 />
<AccountHoverWrapper :disabled="!hoverCard" :account="account">
<AccountBigAvatar :account="account" shrink-0 />
</AccountHoverWrapper>
<div flex="~ col" shrink overflow-hidden>
<div flex="~ col" shrink overflow-hidden justify-center leading-none>
<div flex="~" gap-2>
<ContentRich
font-bold line-clamp-1 ws-pre-wrap break-all
font-bold line-clamp-1 ws-pre-wrap break-all text-lg
:content="getDisplayName(account, { rich: true })"
:emojis="account.emojis"
/>
<AccountBotIndicator v-if="account.bot" />
</div>
<AccountHandle :account="account" text-sm text-secondary-light />
<AccountHandle :account="account" text-secondary-light />
</div>
</component>
</template>

View file

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

View file

@ -9,29 +9,30 @@ let relationship = $(useRelationship(account))
const isSelf = $computed(() => currentUser.value?.account.id === account.id)
const masto = useMasto()
const toggleMute = async () => {
// TODO: Add confirmation
relationship!.muting = !relationship!.muting
relationship = relationship!.muting
? await useMasto().accounts.mute(account.id, {
? await masto.accounts.mute(account.id, {
// TODO support more options
})
: await useMasto().accounts.unmute(account.id)
: await masto.accounts.unmute(account.id)
}
const toggleBlockUser = async () => {
// TODO: Add confirmation
relationship!.blocking = !relationship!.blocking
relationship = await useMasto().accounts[relationship!.blocking ? 'block' : 'unblock'](account.id)
relationship = await masto.accounts[relationship!.blocking ? 'block' : 'unblock'](account.id)
}
const toggleBlockDomain = async () => {
// TODO: Add confirmation
relationship!.domainBlocking = !relationship!.domainBlocking
await useMasto().domainBlocks[relationship!.domainBlocking ? 'block' : 'unblock'](getServerName(account))
await masto.domainBlocks[relationship!.domainBlocking ? 'block' : 'unblock'](getServerName(account))
}
</script>

View file

@ -26,7 +26,7 @@ const followersCountSR = $computed(() => forSR(props.account.followersCount))
<i18n-t keypath="account.posts_count" :plural="account.statusesCount">
<CommonTooltip v-if="statusesCountSR" :content="formatNumber(account.statusesCount)" placement="bottom">
<span aria-hidden="true" font-bold :class="isExactActive ? 'text-primary' : 'text-base'">{{ statusesCount }}</span>
<span sr-only font-bold>{{ account.statusesCount }}</span>
<span sr-only font-bold>{{ formatNumber(account.statusesCount) }}</span>
</CommonTooltip>
<span v-else font-bold :class="isExactActive ? 'text-primary' : 'text-base'">{{ statusesCount }}</span>
</i18n-t>
@ -41,7 +41,7 @@ const followersCountSR = $computed(() => forSR(props.account.followersCount))
<i18n-t keypath="account.following_count" :plural="account.followingCount">
<CommonTooltip v-if="followingCountSR" :content="formatNumber(account.followingCount)" placement="bottom">
<span aria-hidden="true" font-bold :class="isExactActive ? 'text-primary' : 'text-base'">{{ followingCount }}</span>
<span sr-only font-bold>{{ account.followingCount }}</span>
<span sr-only font-bold>{{ formatNumber(account.followingCount) }}</span>
</CommonTooltip>
<span v-else font-bold :class="isExactActive ? 'text-primary' : 'text-base'">{{ followingCount }}</span>
</i18n-t>
@ -56,7 +56,7 @@ const followersCountSR = $computed(() => forSR(props.account.followersCount))
<i18n-t keypath="account.followers_count" :plural="account.followersCount">
<CommonTooltip v-if="followersCountSR" :content="formatNumber(account.followersCount)" placement="bottom">
<span aria-hidden="true" font-bold :class="isExactActive ? 'text-primary' : 'text-base'">{{ followersCount }}</span>
<span sr-only font-bold>{{ account.followersCount }}</span>
<span sr-only font-bold>{{ formatNumber(account.followersCount) }}</span>
</CommonTooltip>
<span v-else font-bold :class="isExactActive ? 'text-primary' : 'text-base'">{{ followersCount }}</span>
</i18n-t>

View file

@ -0,0 +1,55 @@
<script setup lang="ts">
import type { AriaAnnounceType, AriaLive } from '~/composables/aria'
import type { LocaleObject } from '#i18n'
const router = useRouter()
const { t, locale, locales } = useI18n()
const { ariaAnnouncer, announce } = useAriaAnnouncer()
const localeMap = (locales.value as LocaleObject[]).reduce((acc, l) => {
acc[l.code!] = l.name!
return acc
}, {} as Record<string, string>)
let ariaLive = $ref<AriaLive>('polite')
let ariaMessage = $ref<string>('')
const onMessage = (event: AriaAnnounceType, message?: string) => {
if (event === 'announce')
ariaMessage = message!
else if (event === 'mute')
ariaLive = 'off'
else
ariaLive = 'polite'
}
watch(locale, (l, ol) => {
if (ol) {
announce(t('a11y.locale_changing', [localeMap[ol] ?? ol]))
setTimeout(() => {
announce(t('a11y.locale_changed', [localeMap[l] ?? l]))
}, 1000)
}
}, { immediate: true })
onMounted(() => {
ariaAnnouncer.on(onMessage)
router.beforeEach(() => {
announce(t('a11y.loading_page'))
})
router.afterEach((to, from) => {
from && setTimeout(() => {
requestAnimationFrame(() => {
const title = document.title.trim().split('|')
announce(t('a11y.route_loaded', [title[0]]))
})
}, 512)
})
})
</script>
<template>
<p sr-only role="status" :aria-live="ariaLive">
{{ ariaMessage }}
</p>
</template>

View file

@ -0,0 +1,39 @@
<script setup lang="ts">
import type { AriaLive } from '~/composables/aria'
// tsc complaining when using $defineProps
withDefaults(defineProps<{
title: string
ariaLive?: AriaLive
messageKey?: (message: any) => any
heading?: 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
}>(), {
heading: 'h2',
messageKey: (message: any) => message,
ariaLive: 'polite',
})
const { announceLogs, appendLogs, clearLogs, logs } = useAriaLog()
defineExpose({
announceLogs,
appendLogs,
clearLogs,
})
</script>
<template>
<slot />
<div sr-only role="log" :aria-live="ariaLive">
<component :is="heading">
{{ title }}
</component>
<ul>
<li v-for="log in logs" :key="messageKey(log)">
<slot name="log" :log="log">
{{ log }}
</slot>
</li>
</ul>
</div>
</template>

View file

@ -0,0 +1,26 @@
<script setup lang="ts">
import type { AriaLive } from '~/composables/aria'
// tsc complaining when using $defineProps
withDefaults(defineProps<{
ariaLive?: AriaLive
}>(), {
ariaLive: 'polite',
})
const { announceStatus, clearStatus, status } = useAriaStatus()
defineExpose({
announceStatus,
clearStatus,
})
</script>
<template>
<slot />
<p sr-only role="status" :aria-live="ariaLive">
<slot name="status" :status="status">
{{ status }}
</slot>
</p>
</template>

View file

@ -0,0 +1,58 @@
<script lang="ts" setup>
import type { ResolvedCommand } from '@/composables/command'
const emit = defineEmits<{
(event: 'activate'): void
}>()
const {
cmd,
index,
active = false,
} = $defineProps<{
cmd: ResolvedCommand
index: number
active?: boolean
}>()
</script>
<template>
<div
class="flex px-3 py-2 my-1 items-center rounded-lg hover:bg-active transition-all duration-65 ease-in-out cursor-pointer scroll-m-10"
:class="{ 'bg-active': active }"
:data-index="index"
@click="emit('activate')"
>
<div v-if="cmd.icon" me-2 :class="cmd.icon" />
<div class="flex-1 flex items-baseline gap-2">
<div :class="{ 'font-medium': active }">
{{ cmd.name }}
</div>
<div v-if="cmd.description" class="text-xs text-secondary">
{{ cmd.description }}
</div>
</div>
<div
v-if="cmd.onComplete"
class="flex items-center gap-1 transition-all duration-65 ease-in-out"
:class="active ? 'opacity-100' : 'opacity-0'"
>
<div class="text-xs text-secondary">
{{ $t('command.complete') }}
</div>
<CommandKey name="Tab" />
</div>
<div
v-if="cmd.onActivate"
class="flex items-center gap-1 transition-all duration-65 ease-in-out"
:class="active ? 'opacity-100' : 'opacity-0'"
>
<div class="text-xs text-secondary">
{{ $t('command.activate') }}
</div>
<CommandKey name="Enter" />
</div>
</div>
</template>

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import type { CommandScope, QueryIndexedCommand } from '@/composables/command'
import type { SearchResult as SearchResultType } from '@/components/search/types'
import type { CommandScope, QueryResult, QueryResultItem } from '@/composables/command'
const emit = defineEmits<{
(event: 'close'): void
@ -7,6 +8,8 @@ const emit = defineEmits<{
const registry = useCommandRegistry()
const router = useRouter()
const inputEl = $ref<HTMLInputElement>()
const resultEl = $ref<HTMLDivElement>()
@ -18,9 +21,51 @@ onMounted(() => {
})
const commandMode = $computed(() => input.startsWith('>'))
const result = $computed(() => commandMode
const query = $computed(() => commandMode ? '' : input.trim())
const { accounts, hashtags, loading } = useSearch($$(query))
const toSearchQueryResultItem = (search: SearchResultType): QueryResultItem => ({
index: 0,
type: 'search',
search,
onActivate: () => router.push(search.to),
})
const searchResult = $computed<QueryResult>(() => {
if (query.length === 0 || loading.value)
return { length: 0, items: [], grouped: {} as any }
const hashtagList = hashtags.value.slice(0, 3)
.map<SearchResultType>(hashtag => ({ type: 'hashtag', hashtag, to: `/tags/${hashtag.name}` }))
.map(toSearchQueryResultItem)
const accountList = accounts.value
.map<SearchResultType>(account => ({ type: 'account', account, to: `/@${account.acct}` }))
.map(toSearchQueryResultItem)
const grouped: QueryResult['grouped'] = new Map()
grouped.set('Hashtags', hashtagList)
grouped.set('Users', accountList)
let index = 0
for (const items of grouped.values()) {
for (const item of items)
item.index = index++
}
return {
grouped,
items: [...hashtagList, ...accountList],
length: hashtagList.length + accountList.length,
}
})
const result = $computed<QueryResult>(() => commandMode
? registry.query(scopes.map(s => s.id).join('.'), input.slice(1))
: { length: 0, items: [], grouped: {} })
: searchResult,
)
let active = $ref(0)
watch($$(result), (n, o) => {
if (n.length !== o.length || !n.items.every((i, idx) => i === o.items[idx]))
@ -29,7 +74,7 @@ watch($$(result), (n, o) => {
const findItemEl = (index: number) =>
resultEl?.querySelector(`[data-index="${index}"]`) as HTMLDivElement | null
const onCommandActivate = (item: QueryIndexedCommand) => {
const onCommandActivate = (item: QueryResultItem) => {
if (item.onActivate) {
item.onActivate()
emit('close')
@ -39,13 +84,14 @@ const onCommandActivate = (item: QueryIndexedCommand) => {
input = '>'
}
}
const onCommandComplete = (item: QueryIndexedCommand) => {
const onCommandComplete = (item: QueryResultItem) => {
if (item.onComplete) {
scopes.push(item.onComplete())
input = '>'
}
else if (item.onActivate) {
item.onActivate()
emit('close')
}
}
const intoView = (index: number) => {
@ -158,52 +204,30 @@ const onKeyDown = (e: KeyboardEvent) => {
<!-- Results -->
<div ref="resultEl" class="flex-1 mx-1 overflow-y-auto">
<template v-for="[scope, group] in result.grouped" :key="scope">
<div class="mt-2 px-2 py-1 text-sm text-secondary">
{{ scope }}
</div>
<template v-for="cmd in group" :key="cmd.index">
<div
class="flex px-3 py-2 my-1 items-center rounded-lg hover:bg-active transition-all duration-65 ease-in-out cursor-pointer scroll-m-10"
:class="{ 'bg-active': active === cmd.index }"
:data-index="cmd.index"
@click="onCommandActivate(cmd)"
>
<div v-if="cmd.icon" mr-2 :class="cmd.icon" />
<div class="flex-1 flex items-baseline gap-2">
<div :class="{ 'font-medium': active === cmd.index }">
{{ cmd.name }}
</div>
<div v-if="cmd.description" class="text-xs text-secondary">
{{ cmd.description }}
</div>
</div>
<div
v-if="cmd.onComplete"
class="flex items-center gap-1 transition-all duration-65 ease-in-out"
:class="active === cmd.index ? 'opacity-100' : 'opacity-0'"
>
<div class="text-xs text-secondary">
{{ $t('command.complete') }}
</div>
<CommandKey name="Tab" />
</div>
<div
v-if="cmd.onActivate"
class="flex items-center gap-1 transition-all duration-65 ease-in-out"
:class="active === cmd.index ? 'opacity-100' : 'opacity-0'"
>
<div class="text-xs text-secondary">
{{ $t('command.activate') }}
</div>
<CommandKey name="Enter" />
</div>
<template v-if="loading">
<SearchResultSkeleton />
<SearchResultSkeleton />
<SearchResultSkeleton />
</template>
<template v-else-if="result.length">
<template v-for="[scope, group] in result.grouped" :key="scope">
<div class="mt-2 px-2 py-1 text-sm text-secondary">
{{ scope }}
</div>
<template v-for="item in group" :key="item.index">
<SearchResult v-if="item.type === 'search'" :active="active === item.index" :result="item.search" />
<CommandItem v-else :index="item.index" :cmd="item.cmd" :active="active === item.index" @activate="onCommandActivate(item)" />
</template>
</template>
</template>
<div v-else p5 text-center text-secondary italic>
{{
input.length
? $t('common.not_found')
: $t('search.search_desc')
}}
</div>
</div>
<div class="w-full border-b-1 border-base" />

View file

@ -4,14 +4,14 @@ const props = withDefaults(defineProps<{
}>(), {
modelValue: true,
})
const emits = defineEmits<{
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
(event: 'close'): void
}>()
const visible = useVModel(props, 'modelValue', emits, { passive: true })
const visible = useVModel(props, 'modelValue', emit, { passive: true })
function close() {
emits('close')
emit('close')
visible.value = false
}
</script>
@ -19,7 +19,7 @@ function close() {
<template>
<div
flex="~ gap-2" justify-between items-center
class="border-b border-base text-sm text-secondary px4 py2 sm:py4"
border="b base" text-sm text-secondary px4 py2 sm:py4
>
<div>
<slot />

View file

@ -1,5 +1,4 @@
import { decode } from 'blurhash'
import { getDataUrlFromArr } from '~/composables/utils'
export default defineComponent({
inheritAttrs: false,

View file

@ -11,7 +11,7 @@ const { modelValue } = defineModel<{
<template>
<label
class="common-checkbox flex items-center cursor-pointer py-1 text-md w-full gap-y-1"
:class="hover ? 'hover:bg-active ml--2 pl-4' : null"
:class="hover ? 'hover:bg-active ms--2 ps-4' : null"
@click.prevent="modelValue = !modelValue"
>
<span
@ -23,7 +23,7 @@ const { modelValue } = defineModel<{
type="checkbox"
sr-only
>
<span ml-2 pointer-events-none>{{ label }}</span>
<span ms-2 pointer-events-none>{{ label }}</span>
</label>
</template>

View file

@ -0,0 +1,109 @@
<script lang="ts" setup>
import type { Boundaries } from 'vue-advanced-cropper'
import { Cropper } from 'vue-advanced-cropper'
import 'vue-advanced-cropper/dist/style.css'
export interface Props {
/** Images to be cropped */
modelValue?: File
/** Crop frame aspect ratio (width/height), default 1/1 */
stencilAspectRatio?: number
/** The ratio of the longest edge of the cut box to the length of the cut screen, default 0.9, not more than 1 */
stencilSizePercentage?: number
}
const props = withDefaults(defineProps<Props>(), {
stencilAspectRatio: 1 / 1,
stencilSizePercentage: 0.9,
})
const emit = defineEmits<{
(event: 'update:modelValue', value: File): void
}>()
const vmFile = useVModel(props, 'modelValue', emit, { passive: true })
const cropperDialog = ref(false)
const cropper = ref<InstanceType<typeof Cropper>>()
const cropperFlag = ref(false)
const cropperImage = reactive({
src: '',
type: 'image/jpg',
})
const stencilSize = ({ boundaries }: { boundaries: Boundaries }) => {
return {
width: boundaries.width * props.stencilSizePercentage,
height: boundaries.height * props.stencilSizePercentage,
}
}
watch(vmFile, (file, _, onCleanup) => {
let expired = false
onCleanup(() => expired = true)
if (file && !cropperFlag.value) {
cropperDialog.value = true
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = (e) => {
if (expired)
return
cropperImage.src = e.target?.result as string
cropperImage.type = file.type
}
}
cropperFlag.value = false
})
const cropImage = () => {
if (cropper.value && vmFile.value) {
cropperFlag.value = true
cropperDialog.value = false
const { canvas } = cropper.value.getResult()
canvas?.toBlob((blob) => {
vmFile.value = new File([blob as any], `cropped${vmFile.value?.name}` as string, { type: blob?.type })
}, cropperImage.type)
}
}
</script>
<template>
<ModalDialog v-model="cropperDialog" :use-v-if="false" max-w-500px flex>
<div flex-1 w-0>
<div text-lg text-center my2 px3>
<h1>
{{ $t('action.edit') }}
</h1>
</div>
<div aspect-ratio-1>
<Cropper
ref="cropper"
class="overflow-hidden w-full h-full"
:src="cropperImage.src"
:resize-image="{
adjustStencil: false,
}"
:stencil-size="stencilSize"
:stencil-props="{
aspectRatio: props.stencilAspectRatio,
movable: false,
resizable: false,
handlers: {},
}"
image-restriction="stencil"
/>
</div>
<div m-4>
<button
btn-solid w-full rounded text-sm
@click="cropImage()"
>
{{ $t('action.confirm') }}
</button>
</div>
</div>
</ModalDialog>
</template>

View file

@ -0,0 +1,104 @@
<script lang="ts" setup>
import { fileOpen } from 'browser-fs-access'
import type { FileWithHandle } from 'browser-fs-access'
const props = withDefaults(defineProps<{
modelValue?: FileWithHandle
/** The image src before change */
original?: string
/** Allowed file types */
allowedFileTypes?: string[]
/** Allowed file size */
allowedFileSize?: number
imgClass?: string
loading?: boolean
}>(), {
allowedFileTypes: () => ['image/jpeg', 'image/png'],
allowedFileSize: 1024 * 1024 * 5, // 5 MB
})
const emit = defineEmits<{
(event: 'update:modelValue', value: FileWithHandle): void
(event: 'pick', value: FileWithHandle): void
(event: 'error', code: number, message: string): void
}>()
const file = useVModel(props, 'modelValue', emit, { passive: true })
const { t } = useI18n()
const defaultImage = computed(() => props.original || '')
/** Preview of selected images */
const previewImage = ref('')
/** The current images on display */
const imageSrc = computed<string>(() => previewImage.value || defaultImage.value)
const pickImage = async () => {
const image = await fileOpen({
description: 'Image',
mimeTypes: props.allowedFileTypes,
})
if (!props.allowedFileTypes.includes(image.type)) {
emit('error', 1, t('error.unsupported_file_format'))
return
}
else if (image.size > props.allowedFileSize) {
emit('error', 2, t('error.file_size_cannot_exceed_n_mb', [5]))
return
}
file.value = image
emit('pick', file.value)
}
watch(file, (image, _, onCleanup) => {
let expired = false
onCleanup(() => expired = true)
if (!image) {
previewImage.value = ''
return
}
const reader = new FileReader()
reader.readAsDataURL(image)
reader.onload = (evt) => {
if (expired)
return
previewImage.value = evt.target?.result as string
}
})
</script>
<template>
<label
class="bg-slate-500/10 focus-within:(outline outline-primary)"
relative
flex justify-center items-center
cursor-pointer
of-hidden
@click="pickImage"
>
<img
v-if="imageSrc"
:src="imageSrc"
:class="imgClass || ''"
object-cover
w-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">
<div i-ri:upload-line />
</div>
<div
v-if="loading"
absolute inset-0
bg="black/30" text-white
flex justify-center items-center
>
<div class="i-ri:loader-4-line animate-spin animate-duration-[2.5s]" text-4xl />
</div>
</label>
</template>

View file

@ -4,18 +4,28 @@ import { DynamicScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import type { Paginator, WsEvents } from 'masto'
const { paginator, stream, keyProp = 'id', virtualScroller = false, eventType = 'update' } = defineProps<{
const {
paginator,
stream,
keyProp = 'id',
virtualScroller = false,
eventType = 'update',
preprocess,
} = defineProps<{
paginator: Paginator<any, any[]>
keyProp?: string
virtualScroller?: boolean
stream?: WsEvents
stream?: Promise<WsEvents>
eventType?: 'notification' | 'update'
preprocess?: (items: any[]) => any[]
}>()
defineSlots<{
default: {
item: any
active?: boolean
older?: any
newer?: any // newer is undefined when index === 0
}
updater: {
number: number
@ -24,7 +34,7 @@ defineSlots<{
loading: {}
}>()
const { items, prevItems, update, state, endAnchor, error } = usePaginator(paginator, stream, eventType)
const { items, prevItems, update, state, endAnchor, error } = usePaginator(paginator, stream, eventType, preprocess)
</script>
<template>
@ -33,29 +43,34 @@ const { items, prevItems, update, state, endAnchor, error } = usePaginator(pagin
<slot name="items" :items="items">
<template v-if="virtualScroller">
<DynamicScroller
v-slot="{ item, active }"
v-slot="{ item, active, index }"
:items="items"
:min-item-size="200"
:key-field="keyProp"
page-mode
>
<slot :item="item" :active="active" />
<slot
:key="item[keyProp]"
:item="item"
:active="active"
:older="items[index + 1]"
:newer="items[index - 1]"
/>
</DynamicScroller>
</template>
<template v-else>
<slot
v-for="item of items"
v-for="item, index of items"
:key="item[keyProp]"
:item="item"
:older="items[index + 1]"
:newer="items[index - 1]"
/>
</template>
</slot>
<div ref="endAnchor" />
<slot v-if="state === 'loading'" name="loading">
<div p5 text-center flex="~ col" items-center animate-pulse>
<div text-secondary i-ri:loader-2-fill animate-spin text-2xl />
<span text-secondary>{{ $t('state.loading') }}</span>
</div>
<TimelineSkeleton />
</slot>
<div v-else-if="state === 'done'" p5 text-secondary italic text-center>
{{ $t('common.end_of_list') }}

View file

@ -12,7 +12,7 @@ const { modelValue } = defineModel<{
<template>
<label
class="common-radio flex items-center cursor-pointer py-1 text-md w-full gap-y-1"
:class="hover ? 'hover:bg-active ml--2 pl-4' : null"
:class="hover ? 'hover:bg-active ms--2 ps-4' : null"
@click.prevent="modelValue = value"
>
<span
@ -25,7 +25,7 @@ const { modelValue } = defineModel<{
:value="value"
sr-only
>
<span ml-2 pointer-events-none>{{ label }}</span>
<span ms-2 pointer-events-none>{{ label }}</span>
</label>
</template>

View file

@ -5,6 +5,7 @@ const { options, command, replace, preventScrollTop = false } = $defineProps<{
options: {
to: RouteLocationRaw
display: string
disabled?: boolean
name?: string
icon?: string
}[]
@ -28,18 +29,25 @@ useCommands(() => command
<template>
<div flex w-full items-center lg:text-lg of-x-auto scrollbar-hide>
<NuxtLink
<template
v-for="(option, index) in options"
:key="option?.name || index"
:to="option.to"
:replace="replace"
relative flex flex-auto cursor-pointer sm:px6 px2 rounded transition-all
tabindex="1"
hover:bg-active transition-100
exact-active-class="children:(font-bold !border-primary !op100)"
@click="!preventScrollTop && $scrollToTop()"
>
<span ws-nowrap mxa sm:px2 sm:py3 py2 text-center border-b-3 op50 hover:op70 border-transparent>{{ option.display }}</span>
</NuxtLink>
<NuxtLink
v-if="!option.disabled"
:to="option.to"
:replace="replace"
relative flex flex-auto cursor-pointer sm:px6 px2 rounded transition-all
tabindex="1"
hover:bg-active transition-100
exact-active-class="children:(text-secondary !border-primary !op100)"
@click="!preventScrollTop && $scrollToTop()"
>
<span ws-nowrap mxa sm:px2 sm:py3 py2 text-center border-b-3 text-secondary-light hover:text-secondary border-transparent>{{ option.display }}</span>
</NuxtLink>
<div v-else flex flex-auto sm:px6 px2>
<span ws-nowrap mxa sm:px2 sm:py3 py2 text-center text-secondary-light op50>{{ option.display }}</span>
</div>
</template>
</div>
</template>

View file

@ -5,14 +5,20 @@ defineProps<{
}>()
const dropdown = $ref<any>()
const colorMode = useColorMode()
const hide = () => dropdown.hide()
provide(dropdownContextKey, {
hide: () => dropdown.hide(),
hide,
})
defineExpose({
hide,
})
</script>
<template>
<VDropdown v-bind="$attrs" ref="dropdown" :class="{ dark: isDark }" :placement="placement || 'auto'">
<VDropdown v-bind="$attrs" ref="dropdown" :class="colorMode.value" :placement="placement || 'auto'">
<slot />
<template #popper="scope">
<slot name="popper" v-bind="scope" />

View file

@ -1,5 +1,4 @@
import type { Emoji } from 'masto'
import { emojisArrayToObject } from '~/composables/utils'
defineOptions({
name: 'ContentRich',
@ -11,11 +10,21 @@ const { content, emojis, markdown = true } = defineProps<{
emojis?: Emoji[]
}>()
const useEmojis = computed(() => {
const result: Emoji[] = []
if (emojis)
result.push(...emojis)
result.push(...currentCustomEmojis.value.emojis)
return emojisArrayToObject(result)
})
export default () => h(
'span',
{ class: 'content-rich' },
{ class: 'content-rich', dir: 'auto' },
contentToVNode(content, {
emojis: emojisArrayToObject(emojis || []),
emojis: useEmojis.value,
markdown,
}),
)

View file

@ -15,7 +15,7 @@ const withAccounts = $computed(() =>
<StatusCard v-if="conversation.lastStatus" :status="conversation.lastStatus" :actions="false">
<template #meta>
<div flex gap-2 text-sm text-secondary font-bold>
<p mr-1>
<p me-1>
{{ $t('conversation.with') }}
</p>
<AccountAvatar v-for="account in withAccounts" :key="account.id" h-5 w-5 :account="account" />

View file

@ -1,50 +1,16 @@
<script setup lang="ts">
interface Team {
github: string
display: string
twitter: string
mastodon: string
}
const emit = defineEmits<{
(event: 'close'): void
}>()
const teams: Team[] = [
{
github: 'antfu',
display: 'Anthony Fu',
twitter: 'antfu7',
mastodon: 'antfu@mas.to',
},
{
github: 'patak-dev',
display: 'Patak',
twitter: 'patak_dev',
mastodon: 'patak@webtoo.ls',
},
{
github: 'danielroe',
display: 'Daniel Roe',
twitter: 'danielcroe',
mastodon: 'daniel@roe.dev',
},
{
github: 'sxzz',
display: 'sxzz',
twitter: 'sanxiaozhizi',
mastodon: 'sxzz@mas.to',
},
].sort(() => Math.random() - 0.5)
</script>
<template>
<div my-8 px-3 sm:px-8 md:max-w-200 flex="~ col gap-4" relative>
<button btn-action-icon absolute top--8 right-0 m1 aria-label="Close" @click="emit('close')">
<div i-ri:close-fill />
<div i-ri:close-line />
</button>
<img src="/logo.svg" w-20 h-20 height="80" width="80" mxa alt="logo">
<img :alt="$t('app_logo')" src="/logo.svg" w-20 h-20 height="80" width="80" mxa class="rtl-flip">
<h1 mxa text-4xl mb4>
{{ $t('help.title') }}
</h1>
@ -55,19 +21,24 @@ const teams: Team[] = [
<b text-primary>{{ $t('help.desc_highlight') }}</b>
{{ $t('help.desc_para2') }}
</p>
<p>
Before that, if you'd like to help with testing, giving feedback, or contributing, <a font-bold text-primary href="/m.webtoo.ls/@elk" target="_blank">
reach out to us on Mastodon
</a> and get involved.
</p>
{{ $t('help.desc_para3') }}
<p flex="~ gap-2 wrap" mxa>
<template v-for="team of teams" :key="team.github">
<a :href="`https://github.com/sponsors/${team.github}`" target="_blank" rounded-full>
<a :href="`https://github.com/sponsors/${team.github}`" target="_blank" rounded-full transition duration-300 border="~ transparent" hover="scale-105 border-primary">
<img :src="`https://res.cloudinary.com/dchoja2nb/image/twitter_name/h_120,w_120/f_auto/${team.twitter}.jpg`" :alt="team.display" rounded-full w-15 h-15 height="60" width="60">
</a>
</template>
</p>
<p italic text-2xl>
<span text-lg font-script>The Elk Team</span>
<p italic flex justify-center w-full>
<span text-xl font-script>The Elk Team</span>
</p>
<button btn-solid mxa @click="emit('close')">
<button btn-solid mxa tabindex="2" @click="emit('close')">
{{ $t('action.enter_app') }}
</button>
</div>

View file

@ -1,19 +1,27 @@
<script setup lang="ts">
defineProps<{
/** Show the back button on small screens */
backOnSmallScreen?: boolean
/** Show the back button on both small and big screens */
back?: boolean
}>()
</script>
<template>
<div relative>
<div>
<div
sticky top-0 z10
border="b base" bg-base
sticky top-0 z10 backdrop-blur
pt="[env(safe-area-inset-top,0)]"
border="b base" bg="[rgba(var(--c-bg-base-rgb),0.7)]"
>
<div flex justify-between px5 py4>
<div flex gap-3 items-center overflow-hidden>
<NuxtLink v-if="back" flex="~ gap1" items-center btn-text p-0 @click="$router.go(-1)">
<div i-ri:arrow-left-line />
<div flex justify-between px5 py2>
<div flex gap-3 items-center overflow-hidden py2>
<NuxtLink
v-if="backOnSmallScreen || back" flex="~ gap1" items-center btn-text p-0
:class="{ 'lg:hidden': backOnSmallScreen }"
@click="$router.go(-1)"
>
<div i-ri:arrow-left-line class="rtl-flip" />
</NuxtLink>
<div truncate>
<slot name="title" />
@ -22,7 +30,9 @@ defineProps<{
</div>
<div flex items-center flex-shrink-0 gap-x-2>
<slot name="actions" />
<NavUser v-if="isHydrated && isMediumScreen" />
<PwaBadge lg:hidden />
<NavUser v-if="isMastoInitialised" />
<NavUserSkeleton v-else />
</div>
</div>
<slot name="header" />

View file

@ -1,4 +1,5 @@
<script setup lang="ts">
import type { Status } from 'masto'
import {
isCommandPanelOpen,
isEditHistoryDialogOpen,
@ -26,6 +27,15 @@ useEventListener('keydown', (e: KeyboardEvent) => {
openCommandPanel(true)
}
})
const handlePublished = (status: Status) => {
lastPublishDialogStatus.value = status
isPublishDialogOpen.value = false
}
const handlePublishClose = () => {
lastPublishDialogStatus.value = null
}
</script>
<template>
@ -36,13 +46,19 @@ useEventListener('keydown', (e: KeyboardEvent) => {
<ModalDialog v-model="isPreviewHelpOpen" max-w-125>
<HelpPreview @close="closePreviewHelp()" />
</ModalDialog>
<ModalDialog v-model="isPublishDialogOpen" max-w-180 flex>
<ModalDialog
v-model="isPublishDialogOpen"
max-w-180 flex
@close="handlePublishClose"
>
<!-- This `w-0` style is used to avoid overflow problems in flex layoutsso don't remove it unless you know what you're doing -->
<PublishWidget :draft-key="dialogDraftKey" expanded flex-1 w-0 />
<PublishWidget
:draft-key="dialogDraftKey" expanded flex-1 w-0
@published="handlePublished"
/>
</ModalDialog>
<ModalDialog
v-model="isMediaPreviewOpen"
pointer-events-none
w-full max-w-full h-full max-h-full
bg-transparent border-0 shadow-none
>

View file

@ -1,6 +1,5 @@
<script lang="ts" setup>
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
import { useDeactivated } from '~/composables/lifecycle'
export interface Props {
/** v-model dislog visibility */
@ -47,12 +46,13 @@ const props = withDefaults(defineProps<Props>(), {
keepAlive: false,
})
const emits = defineEmits<{
const emit = defineEmits<{
/** v-model dialog visibility */
(event: 'update:modelValue', value: boolean): void
(event: 'close',): void
}>()
const visible = useVModel(props, 'modelValue', emits, { passive: true })
const visible = useVModel(props, 'modelValue', emit, { passive: true })
const deactivated = useDeactivated()
const route = useRoute()
@ -62,7 +62,7 @@ const elDialogMain = ref<HTMLDivElement>()
const elDialogRoot = ref<HTMLDivElement>()
const { activate } = useFocusTrap(elDialogRoot, {
immediate: true,
immediate: false,
allowOutsideClick: true,
clickOutsideDeactivates: true,
escapeDeactivates: true,
@ -76,6 +76,7 @@ defineExpose({
/** close the dialog */
function close() {
visible.value = false
emit('close')
}
function clickMask() {
@ -137,9 +138,9 @@ export default {
</script>
<template>
<Teleport to="body" @transitionend="trapFocusDialog">
<Teleport to="body">
<!-- Dialog component -->
<Transition name="dialog-visible">
<Transition name="dialog-visible" @transitionend="trapFocusDialog">
<div
v-if="isVIf"
v-show="isVShow"

View file

@ -1,10 +1,33 @@
<script setup lang="ts">
import { useImageGesture } from '~/composables/gestures'
const emit = defineEmits(['close'])
const img = ref()
const current = computed(() => mediaPreviewList.value[mediaPreviewIndex.value])
const hasNext = computed(() => mediaPreviewIndex.value < mediaPreviewList.value.length - 1)
const hasPrev = computed(() => mediaPreviewIndex.value > 0)
useImageGesture(img, {
hasNext,
hasPrev,
onNext() {
if (hasNext.value)
mediaPreviewIndex.value++
},
onPrev() {
if (hasPrev.value)
mediaPreviewIndex.value--
},
})
// stop global zooming
useEventListener('wheel', (evt) => {
if (evt.ctrlKey && (evt.deltaY < 0 || evt.deltaY > 0))
evt.preventDefault()
}, { passive: false })
const keys = useMagicKeys()
whenever(keys.arrowLeft, prev)
@ -22,14 +45,14 @@ function prev() {
function onClick(e: MouseEvent) {
const path = e.composedPath() as HTMLElement[]
const el = path.find(el => ['A', 'BUTTON', 'IMG', 'VIDEO'].includes(el.tagName?.toUpperCase()))
const el = path.find(el => ['A', 'BUTTON', 'IMG', 'VIDEO', 'P'].includes(el.tagName?.toUpperCase()))
if (!el)
emit('close')
}
</script>
<template>
<div relative h-full w-full flex select-none pointer-events-none pt-12>
<div relative h-full w-full flex pt-12 @click="onClick">
<button
v-if="hasNext" pointer-events-auto btn-action-icon bg="black/20" :aria-label="$t('action.previous')"
hover:bg="black/40" dark:bg="white/30" dark:hover:bg="white/20" absolute top="1/2" right-1
@ -45,7 +68,10 @@ function onClick(e: MouseEvent) {
<div i-ri:arrow-left-s-line text-white />
</button>
<img
:src="current.url || current.previewUrl" :alt="current.description || ''" max-h-full max-w-full ma
ref="img"
:src="current.url || current.previewUrl"
:alt="current.description || ''"
max-h-full max-w-full ma
>
<div absolute top-0 w-full flex justify-between>
@ -53,14 +79,14 @@ function onClick(e: MouseEvent) {
btn-action-icon bg="black/30" aria-label="action.close" hover:bg="black/40" dark:bg="white/30"
dark:hover:bg="white/20" pointer-events-auto shrink-0 @click="emit('close')"
>
<div i-ri:close-fill text-white />
<div i-ri:close-line text-white />
</button>
<div bg="black/30" dark:bg="white/10" ml-4 my-auto text-white rounded-full flex="~ center" overflow-hidden>
<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>
{{ mediaPreviewIndex + 1 }} / {{ mediaPreviewList.length }}
</div>
<p
v-if="current.description" bg="dark/30" dark:bg="white/10" p="y-1 x-2" rounded-r-full line-clamp-1
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 }}

View file

@ -14,26 +14,30 @@ const moreMenuVisible = ref(false)
<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 />
</NuxtLink>
<NuxtLink to="/search" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
<div i-ri:search-line />
</NuxtLink>
<NuxtLink to="/notifications" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
<div i-ri:notification-4-line />
</NuxtLink>
</template>
<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 />
</NuxtLink>
<NuxtLink group :to="`/${currentServer}/public/local`" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
<div i-ri:group-2-line />
</NuxtLink>
<template v-if="!isMastoInitialised || !currentUser">
<NuxtLink :to="`/${currentServer}/public`" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
<div i-ri:earth-line />
</NuxtLink>
</template>
<template v-if="isMastoInitialised && currentUser">
<NuxtLink to="/conversations" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
<div i-ri:at-line />
</NuxtLink>
</template>
<template v-if="isMastoInitialised && !currentUser">
<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 />
</NuxtLink>
<NuxtLink to="/search" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
<div i-ri:search-line />
</NuxtLink>
<NuxtLink group :to="`/${currentServer}/public/local`" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
<div i-ri:group-2-line />
</NuxtLink>
<NuxtLink :to="`/${currentServer}/public`" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
<div i-ri:earth-line />
</NuxtLink>
</template>
<NavBottomMoreMenu v-slot="{ changeShow, show }" v-model="moreMenuVisible" flex flex-row items-center place-content-center h-full flex-1 cursor-pointer>
<label
flex items-center place-content-center h-full flex-1 class="select-none"

View file

@ -2,10 +2,11 @@
const props = defineProps<{
modelValue?: boolean
}>()
const emits = defineEmits<{
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
}>()
const visible = useVModel(props, 'modelValue', emits, { passive: true })
const visible = useVModel(props, 'modelValue', emit, { passive: true })
const colorMode = useColorMode()
function changeShow() {
visible.value = !visible.value
@ -22,6 +23,10 @@ function clickEvent(mouse: MouseEvent) {
}
}
function toggleDark() {
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
}
watch(visible, (val) => {
if (val && typeof document !== 'undefined')
document.addEventListener('click', clickEvent)
@ -78,37 +83,20 @@ onBeforeUnmount(() => {
hover="bg-gray-100 dark:(bg-gray-700 text-white)"
@click="toggleDark()"
>
<span class="i-ri:sun-line dark:i-ri:moon-line flex-shrink-0 text-xl mr-4 !align-middle" />
{{ !isDark ? $t('menu.toggle_theme.dark') : $t('menu.toggle_theme.light') }}
<span class="i-ri:sun-line dark:i-ri:moon-line flex-shrink-0 text-xl me-4 !align-middle" />
{{ colorMode.value === 'light' ? $t('menu.toggle_theme.dark') : $t('menu.toggle_theme.light') }}
</button>
<!-- Switch languages -->
<NavSelectLanguage>
<button
flex flex-row items-center
block px-5 py-2 focus-blue w-full
text-sm text-base capitalize text-left whitespace-nowrap
transition-colors duration-200 transform
hover="bg-gray-100 dark:(bg-gray-700 text-white)"
@click.stop
>
<span class="i-ri:earth-line flex-shrink-0 text-xl mr-4 !align-middle" />
{{ $t('nav_footer.select_language') }}
</button>
</NavSelectLanguage>
<!-- Toggle Feature Flags -->
<NavSelectFeatureFlags v-if="isMastoInitialised && currentUser">
<button
flex flex-row items-center
block px-5 py-2 focus-blue w-full
text-sm text-base capitalize text-left whitespace-nowrap
transition-colors duration-200 transform
hover="bg-gray-100 dark:(bg-gray-700 text-white)"
@click.stop
>
<span class="i-ri:flag-line flex-shrink-0 text-xl mr-4 !align-middle" />
{{ $t('nav_footer.select_feature_flags') }}
</button>
</NavSelectFeatureFlags>
<NuxtLink
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)"
to="/settings"
>
<span class="i-ri:settings-2-line flex-shrink-0 text-xl me-4 !align-middle" />
{{ $t('nav.settings') }}
</NuxtLink>
</div>
</div>
</div>

View file

@ -1,64 +1,70 @@
<script setup lang="ts">
const buildTime = import.meta.env.__BUILD_TIME__ as string
const buildCommit = import.meta.env.__BUILD_COMMIT__ as string
const buildTimeDate = new Date(buildTime)
import buildInfo from 'virtual:build-info'
const timeAgoOptions = useTimeAgoOptions()
const buildTimeAgo = useTimeAgo(buildTime, timeAgoOptions)
const buildTimeDate = new Date(buildInfo.time)
const buildTimeAgo = useTimeAgo(buildTimeDate, timeAgoOptions)
const colorMode = useColorMode()
function toggleDark() {
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
}
</script>
<template>
<footer p4 text-sm text-secondary-light flex="~ col">
<div flex="~ gap2" items-center mb4>
<CommonTooltip :content="$t('nav_footer.toggle_theme')">
<button flex i-ri:sun-line dark:i-ri:moon-line text-lg :aria-label="$t('nav_footer.toggle_theme')" @click="toggleDark()" />
<CommonTooltip :content="$t('nav.toggle_theme')">
<button flex i-ri:sun-line dark:i-ri:moon-line text-lg :aria-label="$t('nav.toggle_theme')" @click="toggleDark()" />
</CommonTooltip>
<CommonTooltip :content="$t('nav_footer.zen_mode')">
<CommonTooltip :content="$t('nav.zen_mode')">
<button
flex
text-lg
:class="isZenMode ? 'i-ri:layout-right-2-line' : 'i-ri:layout-right-line'"
:aria-label="$t('nav_footer.zen_mode')"
:aria-label="$t('nav.zen_mode')"
@click="toggleZenMode()"
/>
</CommonTooltip>
<NavSelectLanguage>
<CommonTooltip :content="$t('nav_footer.select_language')">
<button flex :aria-label="$t('nav_footer.select_language')">
<div i-ri:earth-line text-lg />
</button>
</CommonTooltip>
</NavSelectLanguage>
<NavSelectFeatureFlags v-if="isMastoInitialised && currentUser">
<CommonTooltip :content="$t('nav_footer.select_feature_flags')">
<button flex :aria-label="$t('nav_footer.select_feature_flags')">
<div i-ri:flag-line text-lg />
</button>
</CommonTooltip>
</NavSelectFeatureFlags>
<CommonTooltip :content="$t('nav.settings')">
<NuxtLink
flex
text-lg
to="/settings"
i-ri:settings-4-line
:aria-label="$t('nav.settings')"
/>
</CommonTooltip>
</div>
<div>
<button cursor-pointer hover:underline @click="openPreviewHelp">
{{ $t('nav_footer.show_intro') }}
{{ $t('nav.show_intro') }}
</button>
</div>
<div>{{ $t('app_desc_short') }}</div>
<div v-if="isMastoInitialised">
<i18n-t keypath="nav_footer.built_at">
<time :datetime="buildTime" :title="$d(buildTimeDate, 'long')">{{ buildTimeAgo }}</time>
<div>
<i18n-t keypath="nav.built_at">
<time :datetime="String(buildTimeDate)" :title="$d(buildTimeDate, 'long')">{{ buildTimeAgo }}</time>
</i18n-t>
&middot;
<NuxtLink
v-if="buildCommit"
external
:href="`https://github.com/elk-zone/elk/commit/${buildCommit}`"
target="_blank"
font-mono
>
{{ buildCommit.slice(0, 7) }}
</NuxtLink>
&middot; <a href="https://github.com/elk-zone/elk" target="_blank">GitHub</a>
<template v-if="buildInfo.version">
&middot;
v{{ buildInfo.version }}
</template>
<template v-if="buildInfo.commit && buildInfo.branch !== 'release'">
&middot;
<NuxtLink
external
:href="`https://github.com/elk-zone/elk/commit/${buildInfo.commit}`"
target="_blank"
font-mono
>
{{ buildInfo.commit.slice(0, 7) }}
</NuxtLink>
</template>
</div>
<div>
<a href="/m.webtoo.ls/@elk" target="_blank">Mastodon</a> &middot; <a href="https://chat.elk.zone" target="_blank">Discord</a> &middot; <a href="https://github.com/elk-zone" target="_blank">GitHub</a>
</div>
</footer>
</template>

View file

@ -1,43 +1,32 @@
<script setup lang="ts">
const { command } = defineProps<{
command?: boolean
}>()
const { notifications } = useNotifications()
</script>
<template>
<nav md:px3 md:py4 flex="~ col gap2" text-size-base leading-normal md:text-lg>
<template v-if="isMastoInitialised && currentUser">
<NavSideItem :text="$t('nav_side.home')" to="/home" icon="i-ri:home-5-line" />
<NavSideItem :text="$t('nav_side.notifications')" to="/notifications" icon="i-ri:notification-4-line">
<template #icon>
<div flex relative>
<div class="i-ri:notification-4-line" md:text-size-inherit text-xl />
<div v-if="notifications" class="top-[-0.3rem] right-[-0.3rem]" absolute font-bold rounded-full h-4 w-4 text-xs bg-primary text-inverted flex items-center justify-center>
{{ notifications < 10 ? notifications : '•' }}
</div>
<nav sm:px3 sm:py4 flex="~ col gap2" text-size-base leading-normal md:text-lg>
<NavSideItem :text="$t('nav.home')" to="/home" icon="i-ri:home-5-line" user-only :command="command" />
<NavSideItem :text="$t('nav.notifications')" to="/notifications" icon="i-ri:notification-4-line" user-only :command="command">
<template #icon>
<div flex relative>
<div class="i-ri:notification-4-line" md:text-size-inherit text-xl />
<div v-if="notifications" class="top-[-0.3rem] right-[-0.3rem]" absolute font-bold rounded-full h-4 w-4 text-xs bg-primary text-inverted flex items-center justify-center>
{{ notifications < 10 ? notifications : '•' }}
</div>
</template>
</NavSideItem>
</template>
<NavSideItem :text="$t('nav_side.explore')" :to="`/${currentServer}/explore`" icon="i-ri:hashtag" />
<NavSideItem :text="$t('nav_side.local')" :to="`/${currentServer}/public/local`" icon="i-ri:group-2-line " />
<NavSideItem :text="$t('nav_side.federated')" :to="`/${currentServer}/public`" icon="i-ri:earth-line" />
<template v-if="isMastoInitialised && currentUser">
<NavSideItem :text="$t('nav_side.conversations')" to="/conversations" icon="i-ri:at-line" />
<NavSideItem :text="$t('nav_side.favourites')" to="/favourites" icon="i-ri:heart-3-line" />
<NavSideItem :text="$t('nav_side.bookmarks')" to="/bookmarks" icon="i-ri:bookmark-line " />
<NavSideItem
v-if="isHydrated && isMediumScreen"
:text="currentUser.account.displayName"
:to="getAccountRoute(currentUser.account)"
icon="i-ri:account-circle-line"
>
<template #icon>
<AccountAvatar :account="currentUser.account" h="1.2em" md:text-size-inherit text-xl />
</template>
<ContentRich
:content="getDisplayName(currentUser.account, { rich: true }) || $t('nav_side.profile')"
:emojis="currentUser.account.emojis"
/>
</NavSideItem>
</template>
</div>
</template>
</NavSideItem>
<!-- Use Search for small screens once the right sidebar is collapsed -->
<NavSideItem :text="$t('nav.search')" to="/search" icon="i-ri:search-line" lg:hidden :command="command" />
<NavSideItem :text="$t('nav.explore')" :to="`/${currentServer}/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.federated')" :to="`/${currentServer}/public`" icon="i-ri:earth-line" :command="command" />
<NavSideItem :text="$t('nav.conversations')" to="/conversations" icon="i-ri:at-line" user-only :command="command" />
<NavSideItem :text="$t('nav.favourites')" to="/favourites" icon="i-ri:heart-3-line" user-only :command="command" />
<NavSideItem :text="$t('nav.bookmarks')" to="/bookmarks" icon="i-ri:bookmark-line " user-only :command="command" />
</nav>
</template>

View file

@ -1,9 +1,15 @@
<script setup lang="ts">
const props = defineProps<{
import { warn } from 'vue'
const props = withDefaults(defineProps<{
text?: string
icon: string
to: string | Record<string, string>
}>()
userOnly?: boolean
command?: boolean
}>(), {
userOnly: false,
})
defineSlots<{
icon: {}
@ -17,22 +23,56 @@ useCommand({
name: () => props.text ?? (typeof props.to === 'string' ? props.to as string : props.to.name),
icon: () => props.icon,
visible: () => props.command,
onActivate() {
router.push(props.to)
},
})
let activeClass = $ref('text-primary')
watch(isMastoInitialised, async () => {
if (!props.userOnly) {
// TODO: force NuxtLink to reevaluate, we now we are in this route though, so we should force it to active
// we don't have currentServer defined until later
activeClass = ''
await nextTick()
activeClass = 'text-primary'
}
})
// Optimize rendering for the common case of being logged in, only show visual feedback for disabled user-only items
// when we know there is no user.
const noUserDisable = computed(() => !isMastoInitialised.value || (props.userOnly && !currentUser.value))
const noUserVisual = computed(() => isMastoInitialised.value && props.userOnly && !currentUser.value)
</script>
<template>
<NuxtLink :to="to" :active-class="isMastoInitialised ? 'text-primary' : ''" group focus:outline-none @click="$scrollToTop">
<div flex w-fit px5 py2 md:gap2 gap4 items-center transition-100 rounded-full group-hover:bg-active group-focus-visible:ring="2 current">
<slot name="icon">
<div :class="icon" md:text-size-inherit text-xl />
</slot>
<slot>
<span>{{ text }}</span>
</slot>
</div>
<NuxtLink
:to="to"
:disabled="noUserDisable"
:class="noUserVisual ? 'op25 pointer-events-none ' : ''"
:active-class="activeClass"
group focus:outline-none disabled:pointer-events-none
:tabindex="noUserDisable ? -1 : null"
@click="$scrollToTop"
>
<CommonTooltip :disabled="!isMediumScreen" :content="text" placement="right">
<div
flex items-center gap4
w-fit rounded-full
px2 py2 mx3 sm:mxa
lg="mx0 px5"
transition-100
group-hover:bg-active group-focus-visible:ring="2 current"
>
<slot name="icon">
<div :class="icon" text-xl />
</slot>
<slot>
<span block sm:hidden lg:block>{{ text }}</span>
</slot>
</div>
</CommonTooltip>
</NuxtLink>
</template>

View file

@ -5,9 +5,18 @@ const sub = env === 'local' ? 'dev' : env === 'staging' ? 'preview' : 'alpha'
<template>
<!-- Use external to force refresh page and jump to top of timeline -->
<NuxtLink flex px3 py2 items-center text-2xl gap-2 hover:bg-active focus-visible:ring="2 current" rounded-full to="/" external>
<img :alt="$t('app_logo')" src="/logo.svg" w-10 h-10 height="40" width="40">
<div>
<NuxtLink
flex items-end gap-2
w-fit
py2 px-2 lg:px-3
text-2xl hover:bg-active
focus-visible:ring="2 current"
rounded-full
to="/"
external
>
<img :alt="$t('app_logo')" src="/logo.svg" shrink-0 aspect="1/1" sm:h-8 lg:h-10 class="rtl-flip">
<div hidden lg:block>
{{ $t('app_name') }} <sup text-sm italic text-secondary mt-1>{{ sub }}</sup>
</div>
</NuxtLink>

View file

@ -1,10 +1,11 @@
<template>
<VDropdown v-if="isMastoInitialised && currentUser">
<VDropdown v-if="isMastoInitialised && currentUser" sm:hidden>
<div style="-webkit-touch-callout: none;">
<AccountAvatar
ref="avatar"
:account="currentUser.account"
h="2em"
h-8
w-8
:draggable="false"
/>
</div>
@ -13,7 +14,7 @@
<UserSwitcher ref="switcher" @click="hide()" />
</template>
</VDropdown>
<button v-else btn-solid text-sm px-2 py-1 text-center @click="openSigninDialog()">
<button v-else btn-solid text-sm px-2 py-1 text-center lg:hidden @click="openSigninDialog()">
{{ $t('action.sign_in') }}
</button>
</template>

View file

@ -0,0 +1,3 @@
<template>
<div bg-base h-8 w-8 rounded-full />
</template>

View file

@ -1,29 +0,0 @@
<script setup lang="ts">
const featureFlags = useFeatureFlags()
</script>
<template>
<CommonDropdown placement="top">
<slot />
<template #popper>
<CommonDropdownItem
:checked="featureFlags.experimentalVirtualScroll"
@click="toggleFeatureFlag('experimentalVirtualScroll')"
>
{{ $t('feature_flag.virtual_scroll') }}
</CommonDropdownItem>
<CommonDropdownItem
:checked="featureFlags.experimentalAvatarOnAvatar"
@click="toggleFeatureFlag('experimentalAvatarOnAvatar')"
>
{{ $t('feature_flag.avatar_on_avatar') }}
</CommonDropdownItem>
<CommonDropdownItem
:checked="featureFlags.experimentalGitHubCards"
@click="toggleFeatureFlag('experimentalGitHubCards')"
>
{{ $t('feature_flag.github_cards') }}
</CommonDropdownItem>
</template>
</CommonDropdown>
</template>

View file

@ -1,24 +0,0 @@
<script lang="ts" setup>
import type { ComputedRef } from 'vue'
import type { LocaleObject } from '#i18n'
const { locale, t, setLocale } = useI18n()
const { locales } = useI18n() as { locales: ComputedRef<LocaleObject[]> }
</script>
<template>
<CommonDropdown>
<slot />
<template #popper>
<CommonDropdownItem
v-for="item in locales"
:key="item.code"
:checked="item.code === locale"
@click="setLocale(item.code)"
>
{{ item.name }}
</CommonDropdownItem>
</template>
</CommonDropdown>
</template>

View file

@ -10,10 +10,16 @@ const { notification } = defineProps<{
<article flex flex-col relative>
<template v-if="notification.type === 'follow'">
<NuxtLink :to="getAccountRoute(notification.account)">
<div flex items-center absolute pl-3 pr-4 py-3 bg-base rounded-br-3 top-0 left-0>
<div i-ri:user-follow-fill mr-1 color-primary />
<div
flex items-center absolute
ps-3 pe-4 inset-is-0
rounded-ie-be-3
py-3 bg-base top-0
:lang="notification.status?.language ?? undefined"
>
<div i-ri:user-follow-fill me-1 color-primary />
<ContentRich
text-primary mr-1 font-bold line-clamp-1 ws-pre-wrap break-all
text-primary me-1 font-bold line-clamp-1 ws-pre-wrap break-all
:content="getDisplayName(notification.account, { rich: true })"
:emojis="notification.account.emojis"
/>
@ -21,24 +27,27 @@ const { notification } = defineProps<{
{{ $t('notification.followed_you') }}
</span>
</div>
<AccountBigCard :account="notification.account" />
<AccountBigCard
:account="notification.account"
:lang="notification.status?.language ?? undefined"
/>
</NuxtLink>
</template>
<template v-else-if="notification.type === 'admin.sign_up'">
<div flex p3 items-center bg-shaded>
<div i-ri:admin-fill mr-1 color-purple />
<div i-ri:admin-fill me-1 color-purple />
<ContentRich
text-purple mr-1 font-bold line-clamp-1 ws-pre-wrap break-all
text-purple me-1 font-bold line-clamp-1 ws-pre-wrap break-all
:content="getDisplayName(notification.account, { rich: true })"
:emojis="notification.account.emojis"
/>
<span>signed up</span>
<span>{{ $t("notification.signed_up") }}</span>
</div>
</template>
<template v-else-if="notification.type === 'follow_request'">
<div flex ml-4 items-center class="-top-2.5" absolute right-2 px-2>
<div i-ri:user-follow-fill text-xl mr-1 />
<AccountInlineInfo :account="notification.account" mr1 />
<div flex ms-4 items-center class="-top-2.5" absolute inset-ie-2 px-2>
<div i-ri:user-follow-fill text-xl me-1 />
<AccountInlineInfo :account="notification.account" me1 />
</div>
<!-- TODO: accept request -->
<AccountCard :account="notification.account" />
@ -46,9 +55,9 @@ const { notification } = defineProps<{
<template v-else-if="notification.type === 'favourite'">
<StatusCard :status="notification.status!" :faded="true">
<template #meta>
<div flex="~" gap-1 items-center>
<div i-ri:heart-fill text-xl mr-1 color-red />
<AccountInlineInfo text-primary font-bold :account="notification.account" mr1 />
<div flex="~" gap-1 items-center mt1>
<div i-ri:heart-fill text-xl me-1 color-red />
<AccountInlineInfo text-primary font-bold :account="notification.account" me1 />
</div>
</template>
</StatusCard>
@ -56,9 +65,9 @@ const { notification } = defineProps<{
<template v-else-if="notification.type === 'reblog'">
<StatusCard :status="notification.status!" :faded="true">
<template #meta>
<div flex="~" gap-1 items-center>
<div i-ri:repeat-fill text-xl mr-1 color-green />
<AccountInlineInfo text-primary font-bold :account="notification.account" mr1 />
<div flex="~" gap-1 items-center mt1>
<div i-ri:repeat-fill text-xl me-1 color-green />
<AccountInlineInfo text-primary font-bold :account="notification.account" me1 />
</div>
</template>
</StatusCard>
@ -66,9 +75,9 @@ const { notification } = defineProps<{
<template v-else-if="notification.type === 'update'">
<StatusCard :status="notification.status!" :faded="true">
<template #meta>
<div flex="~" gap-1 items-center>
<div i-ri:edit-2-fill text-xl mr-1 text-secondary />
<AccountInlineInfo :account="notification.account" mr1 />
<div flex="~" gap-1 items-center mt1>
<div i-ri:edit-2-fill text-xl me-1 text-secondary />
<AccountInlineInfo :account="notification.account" me1 />
<span ws-nowrap>
{{ $t('notification.update_status') }}
</span>

View file

@ -1,16 +1,22 @@
<script setup lang="ts">
defineProps<{
showReAuthMessage: boolean
withHeader?: boolean
busy?: boolean
animate?: boolean
}>()
defineEmits(['hide', 'subscribe'])
defineSlots<{
error: {}
}>()
const isLegacyAccount = computed(() => !currentUser.value?.vapidKey)
</script>
<template>
<div flex="~ col" role="alert" aria-labelledby="notifications-warning" :class="withHeader ? 'border-b border-base' : null">
<div flex="~ col" gap-y-2 role="alert" aria-labelledby="notifications-warning" :class="withHeader ? 'border-b border-base' : null">
<header v-if="withHeader" flex items-center pb-2>
<h2 id="notifications-warning" text-md font-bold w-full>
{{ $t('notification.settings.warning.enable_title') }}
@ -23,21 +29,25 @@ const isLegacyAccount = computed(() => !currentUser.value?.vapidKey)
:disabled="busy"
@click="$emit('hide')"
>
<span aria-hidden="true" i-ri:close-circle-line />
<span aria-hidden="true" i-ri:close-line />
</button>
</header>
<p>
{{ $t(withHeader ? 'notification.settings.warning.enable_description' : 'notification.settings.warning.enable_description_short') }}
</p>
<p v-if="isLegacyAccount && showReAuthMessage">
{{ $t('notification.settings.warning.re_auth') }}
</p>
<button
btn-outline rounded-full font-bold py4 flex="~ gap2 center" m5
type="button"
:class="busy ? 'border-transparent' : null"
:class="busy || isLegacyAccount ? 'border-transparent' : null"
:disabled="busy || isLegacyAccount"
@click="$emit('subscribe')"
>
<span aria-hidden="true" :class="busy && animate ? 'i-ri:loader-2-fill animate-spin' : 'i-ri:check-line'" />
{{ $t('notification.settings.warning.enable_desktop') }}
</button>
<slot v-if="showReAuthMessage" name="error" />
</div>
</template>

View file

@ -10,12 +10,15 @@ const { formatHumanReadableNumber, forSR } = useHumanReadableNumber()
const count = $computed(() => items.items.length)
const addSR = $computed(() => forSR(count))
const isExpanded = ref(false)
const lang = $computed(() => {
return count > 1 || count === 0 ? undefined : items.items[0].status?.language
})
</script>
<template>
<article flex flex-col relative>
<article flex flex-col relative :lang="lang ?? undefined">
<div flex items-center top-0 left-2 pt-2 px-3>
<div i-ri:user-follow-fill mr-3 color-primary aria-hidden="true" />
<div i-ri:user-follow-fill me-3 color-primary aria-hidden="true" />
<template v-if="count > 1">
<template v-if="addSR">
<span
@ -33,11 +36,11 @@ const isExpanded = ref(false)
</template>
<template v-else>
<ContentRich
text-primary mr-1 font-bold line-clamp-1 ws-pre-wrap break-all
text-primary me-1 font-bold line-clamp-1 ws-pre-wrap break-all
:content="getDisplayName(items.items[0]?.account, { rich: true })"
:emojis="items.items[0]?.account.emojis"
/>
<span mr-1 ws-nowrap>
<span me-1 ws-nowrap>
{{ $t('notification.followed_you') }}
</span>
</template>

View file

@ -10,12 +10,12 @@ const { group } = defineProps<{
<article flex flex-col relative>
<StatusCard :status="group.status!" :faded="true">
<template #meta>
<div flex flex-col gap-2>
<div flex flex-col gap-1 mt-1>
<div v-for="like of group.likes" :key="like.account.id" flex>
<div v-if="like.reblog" i-ri:repeat-fill text-xl mr-2 color-green />
<div v-if="like.favourite && !like.reblog" i-ri:heart-fill text-xl mr-2 color-red />
<AccountInlineInfo text-primary font-bold :account="like.account" mr2 />
<div v-if="like.favourite && like.reblog" i-ri:heart-fill text-xl mr-2 color-red />
<div v-if="like.reblog" i-ri:repeat-fill text-xl me-2 color-green />
<div v-if="like.favourite && !like.reblog" i-ri:heart-fill text-xl me-2 color-red />
<AccountInlineInfo text-primary font-bold :account="like.account" me2 />
<div v-if="like.favourite && like.reblog" i-ri:heart-fill text-xl me-2 color-red />
</div>
</div>
</template>

View file

@ -4,7 +4,7 @@ import type { GroupedAccountLike, NotificationSlot } from '~/types'
const { paginator, stream } = defineProps<{
paginator: Paginator<any, Notification[]>
stream?: WsEvents
stream?: Promise<WsEvents>
}>()
const groupCapacity = Number.MAX_VALUE // No limit
@ -70,7 +70,7 @@ function groupItems(items: Notification[]): NotificationSlot[] {
}
like[notification.type === 'reblog' ? 'reblog' : 'favourite'] = notification
}
likes.sort((a, b) => b.reblog && !a.reblog ? 1 : -1)
likes.sort((a, b) => a.reblog ? !b.reblog || (a.favourite && !b.favourite) ? -1 : 0 : 0)
results.push({
id: `grouped-${id++}`,
type: 'grouped-reblogs-and-favourites',
@ -99,13 +99,14 @@ function groupItems(items: Notification[]): NotificationSlot[] {
}
const { clearNotifications } = useNotifications()
const { formatNumber } = useHumanReadableNumber()
</script>
<template>
<CommonPaginator :paginator="paginator" :stream="stream" :eager="3" event-type="notification">
<template #updater="{ number, update }">
<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]) }}
{{ $t('timeline.show_new_items', number, { named: { v: formatNumber(number) } }) }}
</button>
</template>
<template #items="{ items }">

View file

@ -1,13 +1,9 @@
<script setup lang="ts">
import { usePushManager } from '~/composables/push-notifications/usePushManager'
import NotificationSubscribePushNotificationError
from '~/components/notification/NotificationSubscribePushNotificationError.vue'
defineProps<{ show: boolean }>()
let busy = $ref<boolean>(false)
let animateSave = $ref<boolean>(false)
let animateSubscription = $ref<boolean>(false)
let animateRemoveSubscription = $ref<boolean>(false)
const {
pushNotificationData,
saveEnabled,
@ -20,9 +16,17 @@ const {
subscribe,
unsubscribe,
} = usePushManager()
const { t } = useI18n()
const pwaEnabled = useRuntimeConfig().public.pwaEnabled
let busy = $ref<boolean>(false)
let animateSave = $ref<boolean>(false)
let animateSubscription = $ref<boolean>(false)
let animateRemoveSubscription = $ref<boolean>(false)
let subscribeError = $ref<string>('')
let showSubscribeError = $ref<boolean>(false)
const hideNotification = () => {
const key = currentUser.value?.account?.acct
if (key)
@ -65,10 +69,15 @@ const doSubscribe = async () => {
animateSubscription = true
try {
const subscription = await subscribe()
// todo: apply some logic based on the result: subscription === 'subscribed'
// todo: maybe throwing an error instead just a literal to show a dialog with the error
// todo: handle error
const result = await subscribe()
if (result !== 'subscribed') {
subscribeError = t(`notification.settings.subscription_error.${result === 'notification-denied' ? 'permission_denied' : 'request_error'}`)
showSubscribeError = true
}
}
catch {
subscribeError = t('notification.settings.subscription_error.request_error')
showSubscribeError = true
}
finally {
busy = false
@ -161,9 +170,19 @@ onActivated(() => (busy = false))
v-else
:animate="animateSubscription"
:busy="busy"
:show-re-auth-message="!showWarning"
@hide="hideNotification"
@subscribe="doSubscribe"
/>
>
<template #error>
<Transition name="slide-down">
<NotificationSubscribePushNotificationError
v-model="showSubscribeError"
:message="subscribeError"
/>
</transition>
</template>
</NotificationEnablePushNotification>
</template>
</template>
<p v-else role="alert" aria-labelledby="notifications-unsupported">
@ -173,6 +192,7 @@ onActivated(() => (busy = false))
</Transition>
<NotificationEnablePushNotification
v-if="showWarning"
show-re-auth-message
with-header
px5
py4
@ -180,6 +200,15 @@ onActivated(() => (busy = false))
:busy="busy"
@hide="hideNotification"
@subscribe="doSubscribe"
/>
>
<template #error>
<Transition name="slide-down">
<NotificationSubscribePushNotificationError
v-model="showSubscribeError"
:message="subscribeError"
/>
</Transition>
</template>
</NotificationEnablePushNotification>
</div>
</template>

View file

@ -0,0 +1,40 @@
<script setup lang="ts">
defineProps<{
title?: string
message: string
}>()
const { modelValue } = defineModel<{
modelValue: boolean
}>()
</script>
<template>
<div
v-if="modelValue"
role="alert"
aria-describedby="notification-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="notification-failed" flex justify-between>
<div flex items-center gap-x-2 font-bold>
<div aria-hidden="true" i-ri:error-warning-fill />
<p>{{ title ?? $t('notification.settings.subscription_error.title') }}</p>
</div>
<CommonTooltip placement="bottom" :content="$t('notification.settings.subscription_error.clear_error')">
<button
flex rounded-4 p1
hover:bg-active cursor-pointer transition-100
:aria-label="$t('notification.settings.subscription_error.clear_error')"
@click="modelValue = false"
>
<span aria-hidden="true" w-1.75em h-1.75em i-ri:close-line />
</button>
</CommonTooltip>
</head>
<p>{{ message }}</p>
</div>
</template>

View file

@ -10,13 +10,17 @@ const props = withDefaults(defineProps<{
removable: true,
})
defineEmits<{
const emit = defineEmits<{
(evt: 'remove'): void
(evt: 'setDescription', description: string): void
}>()
const isEditDialogOpen = ref(false)
const description = ref(props.attachment.description ?? '')
const toggleApply = () => {
isEditDialogOpen.value = false
emit('setDescription', unref(description))
}
</script>
<template>
@ -25,7 +29,7 @@ const description = ref(props.attachment.description ?? '')
<div absolute right-2 top-2>
<div
v-if="removable"
aria-label="Remove attachment"
:aria-label="$t('attachment.remove_label')"
hover:bg="gray/40" transition-100 p-1 rounded-5 cursor-pointer
:class="[isHydrated && isSmallScreen ? '' : 'op-0 group-hover:op-100hover:']"
mix-blend-difference
@ -36,7 +40,7 @@ const description = ref(props.attachment.description ?? '')
</div>
<div absolute right-2 bottom-2>
<button class="bg-black/75" text-white px2 py1 rounded-2 @click="isEditDialogOpen = true">
Edit
{{ $t('action.edit') }}
</button>
</div>
<ModalDialog
@ -45,19 +49,19 @@ const description = ref(props.attachment.description ?? '')
py-6
px-6 max-w-300
>
<div flex gap-5>
<div flex flex-col-reverse gap-5 md:flex-row>
<div flex flex-col gap-2 justify-between>
<h1 id="edit-attachment" font-bold>
Description
{{ $t('attachment.edit_title') }}
</h1>
<div flex flex-col gap-2>
<textarea v-model="description" p-3 w-100 h-50 bg-base rounded-2 border-strong border-1 />
<button btn-outline @click="$emit('setDescription', description)">
Apply
<textarea v-model="description" p-3 h-50 bg-base rounded-2 border-strong border-1 md:w-100 />
<button btn-outline @click="toggleApply">
{{ $t('action.apply') }}
</button>
</div>
<button btn-outline @click="isEditDialogOpen = false">
Close
{{ $t('action.close') }}
</button>
</div>
<StatusAttachment :attachment="attachment" w-full />

View file

@ -1,6 +1,22 @@
<script setup>
const disabled = computed(() => !isMastoInitialised.value || !currentUser.value)
const disabledVisual = computed(() => isMastoInitialised.value && !currentUser.value)
</script>
<template>
<button btn-outline rounded-full font-bold py4 flex="~ gap2 center" @click="openPublishDialog()">
<button
flex="~ gap2 center"
w-9 h-9 py2
lg="w-auto h-auto py-4"
rounded-full
cursor-pointer disabled:pointer-events-none
text-primary font-bold
border-1 border-primary
:class="disabledVisual ? 'op25' : 'hover:bg-primary hover:text-inverted'"
:disabled="disabled"
@click="openPublishDialog()"
>
<div i-ri:quill-pen-line />
{{ $t('action.compose') }}
<span hidden lg:block>{{ $t('action.compose') }}</span>
</button>
</template>

View file

@ -0,0 +1,58 @@
<script setup lang="ts">
import type { Picker } from 'emoji-mart'
const emit = defineEmits<{
(e: 'select', code: string): void
(e: 'selectCustom', image: any): void
}>()
const el = $ref<HTMLElement>()
let picker = $ref<Picker>()
const colorMode = useColorMode()
async function openEmojiPicker() {
await updateCustomEmojis()
if (picker) {
picker.update({
theme: colorMode.value,
custom: customEmojisData.value,
})
}
else {
const promise = import('@emoji-mart/data').then(r => r.default)
const { Picker } = await import('emoji-mart')
picker = new Picker({
data: () => promise,
onEmojiSelect({ native, src, alt, name }: any) {
native
? emit('select', native)
: emit('selectCustom', { src, alt, 'data-emoji-id': name })
},
theme: colorMode.value,
custom: customEmojisData.value,
})
}
await nextTick()
// TODO: custom picker
el?.appendChild(picker as any as HTMLElement)
}
const hideEmojiPicker = () => {
if (picker)
el?.removeChild(picker as any as HTMLElement)
}
</script>
<template>
<VDropdown
@apply-show="openEmojiPicker()"
@apply-hide="hideEmojiPicker()"
>
<button btn-action-icon :title="$t('tooltip.emoji')">
<div i-ri:emotion-line />
</button>
<template #popper>
<div ref="el" min-w-10 min-h-10 />
</template>
</VDropdown>
</template>

View file

@ -1,10 +1,14 @@
<script setup lang="ts">
import type { Attachment, CreateStatusParams, StatusVisibility } from 'masto'
import type { Attachment, CreateStatusParams, Status, StatusVisibility } from 'masto'
import { fileOpen } from 'browser-fs-access'
import { useDropZone } from '@vueuse/core'
import { EditorContent } from '@tiptap/vue-3'
import ISO6391 from 'iso-639-1'
import Fuse from 'fuse.js'
import type { Draft } from '~/types'
type FileUploadError = [filename: string, message: string]
const {
draftKey,
initial = getDefaultDraft() as never /* Bug of vue-core */,
@ -21,7 +25,9 @@ const {
dialogLabelledBy?: string
}>()
const emit = defineEmits(['published'])
const emit = defineEmits<{
(evt: 'published', status: Status): void
}>()
const { t } = useI18n()
// eslint-disable-next-line prefer-const
@ -54,6 +60,8 @@ const currentVisibility = $computed(() => {
})
let isUploading = $ref<boolean>(false)
let isExceedingAttachmentLimit = $ref<boolean>(false)
let failed = $ref<FileUploadError[]>([])
async function handlePaste(evt: ClipboardEvent) {
const files = evt.clipboardData?.files
@ -64,25 +72,20 @@ async function handlePaste(evt: ClipboardEvent) {
await uploadAttachments(Array.from(files))
}
function insertEmoji(name: string) {
editor.value?.chain().focus().insertEmoji(name).run()
}
function insertCustomEmoji(image: any) {
editor.value?.chain().focus().insertCustomEmoji(image).run()
}
async function pickAttachments() {
const files = await fileOpen([
{
description: 'Attachments',
multiple: true,
mimeTypes: ['image/*'],
extensions: ['.png', '.gif', '.jpeg', '.jpg', '.webp', '.avif', '.heic', '.heif'],
},
{
description: 'Attachments',
mimeTypes: ['video/*'],
extensions: ['.webm', '.mp4', '.m4v', '.mov', '.ogv', '.3gp'],
},
{
description: 'Attachments',
mimeTypes: ['audio/*'],
extensions: ['.mp3', '.ogg', '.oga', '.wav', '.flac', '.opus', '.aac', '.m4a', '.3gp', '.wma'],
},
])
const mimeTypes = currentInstance.value!.configuration.mediaAttachments.supportedMimeTypes
const files = await fileOpen({
description: 'Attachments',
multiple: true,
mimeTypes,
})
await uploadAttachments(files)
}
@ -90,20 +93,40 @@ async function toggleSensitive() {
draft.params.sensitive = !draft.params.sensitive
}
const masto = useMasto()
async function uploadAttachments(files: File[]) {
isUploading = true
for (const file of files) {
const attachment = await useMasto().mediaAttachments.create({
file,
})
draft.attachments.push(attachment)
failed = []
// TODO: display some kind of message if too many media are selected
// DONE
const limit = currentInstance.value!.configuration.statuses.maxMediaAttachments || 4
for (const file of files.slice(0, limit)) {
if (draft.attachments.length < limit) {
isExceedingAttachmentLimit = false
try {
const attachment = await masto.mediaAttachments.create({
file,
})
draft.attachments.push(attachment)
}
catch (e) {
// TODO: add some human-readable error message, problem is that masto api will not return response code
console.error(e)
failed = [...failed, [file.name, (e as Error).message]]
}
}
else {
isExceedingAttachmentLimit = true
failed = [...failed, [file.name, t('state.attachments_limit_error')]]
}
}
isUploading = false
}
async function setDescription(att: Attachment, description: string) {
att.description = description
await useMasto().mediaAttachments.update(att.id, { description: att.description })
await masto.mediaAttachments.update(att.id, { description: att.description })
}
function removeAttachment(index: number) {
@ -114,6 +137,10 @@ function chooseVisibility(visibility: StatusVisibility) {
draft.params.visibility = visibility
}
function chooseLanguage(language: string | null) {
draft.params.language = language
}
async function publish() {
const payload = {
...draft.params,
@ -136,14 +163,14 @@ async function publish() {
try {
isSending = true
let status: Status
if (!draft.editingStatus)
await useMasto().statuses.create(payload)
status = await masto.statuses.create(payload)
else
await useMasto().statuses.update(draft.editingStatus.id, payload)
status = await masto.statuses.update(draft.editingStatus.id, payload)
draft = initial()
isPublishDialogOpen.value = false
emit('published')
emit('published', status)
}
finally {
isSending = false
@ -159,6 +186,29 @@ async function onDrop(files: File[] | null) {
const { isOverDropZone } = useDropZone(dropZoneRef, onDrop)
const languageKeyword = $ref('')
const languageList: {
code: string | null
nativeName: string
name?: string
}[] = [{
code: null,
nativeName: t('language.none'),
}, ...ISO6391.getAllCodes().map(code => ({
code,
nativeName: ISO6391.getNativeName(code),
name: ISO6391.getName(code),
}))]
const fuse = new Fuse(languageList, {
keys: ['code', 'nativeName', 'name'],
shouldSort: true,
})
const languages = $computed(() =>
languageKeyword.trim()
? fuse.search(languageKeyword).map(r => r.item)
: languageList,
)
defineExpose({
focusEditor: () => {
editor.value?.commands?.focus?.()
@ -167,7 +217,7 @@ defineExpose({
</script>
<template>
<div v-if="isMastoInitialised && currentUser" flex="~ col gap-4" py4 px2 sm:px4>
<div v-if="isMastoInitialised && currentUser" flex="~ col gap-4" py3 px2 sm:px4>
<template v-if="draft.editingStatus">
<div flex="~ col gap-1">
<div id="state-editing" text-secondary self-center>
@ -178,9 +228,9 @@ defineExpose({
<div border="b dashed gray/40" />
</template>
<div flex gap-4 flex-1>
<NuxtLink w-12 h-12 :to="getAccountRoute(currentUser.account)">
<AccountAvatar :account="currentUser.account" f-full h-full />
<div flex gap-3 flex-1>
<NuxtLink :to="getAccountRoute(currentUser.account)">
<AccountBigAvatar :account="currentUser.account" />
</NuxtLink>
<!-- This `w-0` style is used to avoid overflow problems in flex layoutsso don't remove it unless you know what you're doing -->
<div
@ -205,15 +255,48 @@ defineExpose({
flex max-w-full
:class="shouldExpanded ? 'min-h-30 md:max-h-[calc(100vh-200px)] sm:max-h-[calc(100vh-400px)] max-h-35 of-y-auto overscroll-contain' : ''"
/>
<div v-if="shouldExpanded" absolute right-0 bottom-0 pointer-events-none text-sm text-secondary-light>
{{ characterLimit - editor?.storage.characterCount.characters() }}
</div>
</div>
<div v-if="isUploading" flex gap-1 items-center text-sm p1 text-primary>
<div i-ri:loader-2-fill animate-spin />
{{ $t('state.uploading') }}
</div>
<div
v-else-if="failed.length > 0"
role="alert"
: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>
<div flex items-center gap-x-2 font-bold>
<div aria-hidden="true" i-ri:error-warning-fill />
<p>{{ $t('state.upload_failed') }}</p>
</div>
<CommonTooltip placement="bottom" :content="$t('action.clear_upload_failed')">
<button
flex rounded-4 p1
hover:bg-active cursor-pointer transition-100
:aria-label="$t('action.clear_upload_failed')"
@click="failed = []"
>
<span aria-hidden="true" w-1.75em h-1.75em i-ri:close-line />
</button>
</CommonTooltip>
</head>
<div v-if="isExceedingAttachmentLimit" id="uploads-per-post" ps-2 sm:ps-1 text-small>
{{ $t('state.attachments_exceed_server_limit') }}
</div>
<ol ps-2 sm:ps-1>
<li v-for="error in failed" :key="error[0]" flex="~ col sm:row" gap-y-1 sm:gap-x-2>
<strong>{{ error[1] }}:</strong>
<span>{{ error[0] }}</span>
</li>
</ol>
</div>
<div v-if="draft.attachments.length" flex="~ col gap-2" overflow-auto>
<PublishAttachment
@ -229,9 +312,14 @@ defineExpose({
<div flex gap-4>
<div w-12 h-full sm:block hidden />
<div
v-if="shouldExpanded" flex="~ gap-2 1" m="l--1" pt-2 justify="between" max-full
v-if="shouldExpanded" flex="~ gap-1 1 wrap" m="s--1" pt-2 justify="between" max-w-full
border="t base"
>
<PublishEmojiPicker
@select="insertEmoji"
@select-custom="insertCustomEmoji"
/>
<CommonTooltip placement="bottom" :content="$t('tooltip.add_media')">
<button btn-action-icon :aria-label="$t('tooltip.add_media')" @click="pickAttachments">
<div i-ri:image-add-line />
@ -243,7 +331,7 @@ defineExpose({
<button
btn-action-icon
:aria-label="$t('tooltip.toggle_code_block')"
:class="editor.isActive('codeBlock') ? 'op100' : 'op50'"
:class="editor.isActive('codeBlock') ? 'text-primary' : ''"
@click="editor?.chain().focus().toggleCodeBlock().run()"
>
<div i-ri:code-s-slash-line />
@ -253,6 +341,10 @@ defineExpose({
<div flex-auto />
<div dir="ltr" pointer-events-none pe-1 pt-2 text-sm tabular-nums text-secondary flex gap-0.5>
{{ editor?.storage.characterCount.characters() }}<span text-secondary-light>/</span><span text-secondary-light>{{ characterLimit }}</span>
</div>
<CommonTooltip placement="bottom" :content="$t('tooltip.add_content_warning')">
<button btn-action-icon :aria-label="$t('tooltip.add_content_warning')" @click="toggleSensitive">
<div v-if="draft.params.sensitive" i-ri:alarm-warning-fill text-orange />
@ -260,11 +352,46 @@ defineExpose({
</button>
</CommonTooltip>
<CommonTooltip placement="bottom" :content="$t('tooltip.change_content_visibility')">
<CommonTooltip placement="top" :content="$t('tooltip.change_language')">
<CommonDropdown placement="bottom">
<button btn-action-icon :aria-label="$t('tooltip.change_language')" w-12 mr--1>
<div i-ri:translate-2 />
<div i-ri:arrow-down-s-line text-sm text-secondary me--1 />
</button>
<template #popper>
<div min-w-80 p3>
<input
v-model="languageKeyword"
:placeholder="t('language.search')"
p2 mb2 border-rounded w-full bg-transparent
outline-none border="~ base"
>
<div max-h-40vh overflow-auto>
<CommonDropdownItem
v-for="{ code, nativeName, name } in languages"
:key="code"
:checked="code === (draft.params.language || null)"
@click="chooseLanguage(code)"
>
{{ nativeName }}
<template #description>
<template v-if="name">
{{ name }}
</template>
</template>
</CommonDropdownItem>
</div>
</div>
</template>
</CommonDropdown>
</CommonTooltip>
<CommonTooltip placement="bottom" :content="draft.editingStatus ? $t(`visibility.${currentVisibility.value}`) : $t('tooltip.change_content_visibility')">
<CommonDropdown>
<button :aria-label="$t('tooltip.change_content_visibility')" btn-action-icon w-12>
<button :disabled="!!draft.editingStatus" :aria-label="$t('tooltip.change_content_visibility')" btn-action-icon :class="{ 'w-12': !draft.editingStatus }">
<div :class="currentVisibility.icon" />
<div i-ri:arrow-down-s-line text-sm text-secondary mr--1 />
<div v-if="!draft.editingStatus" i-ri:arrow-down-s-line text-sm text-secondary me--1 />
</button>
<template #popper>
@ -285,7 +412,7 @@ defineExpose({
</CommonTooltip>
<button
btn-solid rounded-full text-sm
btn-solid rounded-full text-sm w-full md:w-fit
:disabled="isEmpty || isUploading || (draft.attachments.length === 0 && !draft.params.status)"
@click="publish"
>

View file

@ -0,0 +1,13 @@
<template>
<button
v-if="$pwa?.needRefresh"
bg="fade" relative rounded
flex="~ gap-1 center" px3 py1 text-primary
@click="$pwa.updateServiceWorker()"
>
<div i-ri-download-cloud-2-line />
<h2 flex="~ gap-2" items-center>
{{ $t('pwa.update_available_short') }}
</h2>
</button>
</template>

View file

@ -0,0 +1,21 @@
<template>
<div
v-if="$pwa?.needRefresh"
m-2 p5 bg="fade" relative
rounded-lg of-hidden
flex="~ col gap-3"
>
<h2 flex="~ gap-2" items-center>
{{ $t('pwa.title') }}
</h2>
<div flex="~ gap-1">
<button type="button" btn-solid px-4 py-1 text-center text-sm @click="$pwa.updateServiceWorker()">
{{ $t('pwa.update') }}
</button>
<button type="button" btn-text filter-saturate-0 px-4 py-1 text-center text-sm @click="$pwa.close()">
{{ $t('pwa.dismiss') }}
</button>
</div>
<div i-ri-arrow-down-circle-line absolute text-8em bottom--10 inset-ie--10 text-primary op10 class="-z-1" />
</div>
</template>

View file

@ -11,6 +11,7 @@ const onActivate = () => {
<CommonScrollIntoView as="RouterLink" :active="active" :to="result.to" py2 block px2 :aria-selected="active" :class="{ 'bg-active': active }" hover:bg-active @click="() => onActivate()">
<SearchHashtagInfo v-if="result.type === 'hashtag'" :hashtag="result.hashtag" />
<AccountInfo v-else-if="result.type === 'account'" :account="result.account" />
<StatusCard v-else-if="result.type === 'status'" :status="result.status" :actions="false" :show-reply-to="false" />
<div v-else-if="result.type === 'action'" text-center>
{{ result.action!.label }}
</div>

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
const query = ref('')
const { accounts, hashtags, loading } = useSearch(query)
const { accounts, hashtags, loading, statuses } = useSearch(query)
const index = ref(0)
const { t } = useI18n()
@ -13,8 +13,9 @@ const results = computed(() => {
return []
const results = [
...hashtags.value.slice(0, 3).map(hashtag => ({ type: 'hashtag', hashtag, to: `/tags/${hashtag.name}` })),
...accounts.value.map(account => ({ type: 'account', account, to: `/@${account.acct}` })),
...hashtags.value.slice(0, 3).map(hashtag => ({ type: 'hashtag', hashtag, to: getTagRoute(hashtag.name) })),
...accounts.value.map(account => ({ type: 'account', account, to: getAccountRoute(account) })),
...statuses.value.map(status => ({ type: 'status', status, to: getStatusRoute(status) })),
// Disable until search page is implemented
// {
@ -52,19 +53,19 @@ const activate = () => {
<template>
<div ref="el" relative px4 py2 group>
<div bg-base border="~ base" h10 rounded-full flex="~ row" items-center relative outline-primary outline-1 focus-within:outline transition-all transition-duration-500>
<div i-ri:search-2-line mx4 absolute pointer-events-none text-secondary mt="1px" />
<div bg-base border="~ base" h10 rounded-full flex="~ row" items-center relative focus-within:box-shadow-outline>
<div i-ri:search-2-line mx4 absolute pointer-events-none text-secondary mt="1px" class="rtl-flip" />
<input
ref="input"
v-model="query"
h-full
pl-10
ps-10
rounded-full
w-full
bg-transparent
outline="focus:none"
pr-4
:placeholder="`${t('nav_side.search')} Elk`"
pe-4
:placeholder="t('nav.search')"
pb="1px"
placeholder-text-secondary
@keydown.down.prevent="shift(1)"

View file

@ -1,10 +1,11 @@
import type { Account } from 'masto'
import type { Account, Status } from 'masto'
export interface SearchResult {
type: 'account' | 'hashtag' | 'action'
type: 'account' | 'hashtag' | 'action' | 'status'
to: string
label?: string
account?: Account
status?: Status
hashtag?: any
action?: {
label: string

View file

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

View file

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

View file

@ -0,0 +1,76 @@
<script lang="ts" setup>
const props = defineProps<{
text?: string
content?: string
description?: string
icon?: string
to?: string | Record<string, string>
command?: boolean
}>()
const router = useRouter()
useCommand({
scope: 'Settings',
name: () => props.text
?? (props.to
? typeof props.to === 'string'
? props.to
: props.to.name
: ''
),
description: () => props.description,
icon: () => props.icon || '',
visible: () => props.command && props.to,
onActivate() {
router.push(props.to!)
},
})
</script>
<template>
<NuxtLink
:to="to"
exact-active-class="text-primary"
block w-full group focus:outline-none
@click="to ? $scrollToTop() : undefined"
>
<div
w-full flex w-fit px5 py3 md:gap2 gap4 items-center
transition-250 group-hover:bg-active
group-focus-visible:ring="2 current"
>
<div flex-1 flex items-center md:gap2 gap4>
<div
v-if="$slots.icon || icon"
flex items-center justify-center flex-shrink-0
:class="$slots.description ? 'w-12 h-12' : ''"
>
<slot name="icon">
<div v-if="icon" :class="icon" md:text-size-inherit text-xl />
</slot>
</div>
<div space-y-1>
<p>
<slot>
<span>{{ text }}</span>
</slot>
</p>
<p v-if="$slots.description || description" text-sm text-secondary>
<slot name="description">
{{ description }}
</slot>
</p>
</div>
</div>
<p v-if="$slots.content || content" text-sm text-secondary>
<slot name="content">
{{ content }}
</slot>
</p>
<div v-if="to" i-ri:arrow-right-s-line text-xl text-secondary-light class="rtl-flip" />
</div>
</NuxtLink>
</template>

View file

@ -0,0 +1,15 @@
<script lang="ts" setup>
import type { ComputedRef } from 'vue'
import type { LocaleObject } from '#i18n'
const { locale, setLocale } = useI18n()
const { locales } = useI18n() as { locales: ComputedRef<LocaleObject[]> }
</script>
<template>
<select :value="locale" @input="e => setLocale((e.target as any).value)">
<option v-for="item in locales" :key="item.code" :value="item.code" :selected="locale === item.code">
{{ item.name }}
</option>
</select>
</template>

View file

@ -0,0 +1,64 @@
<script setup lang="ts">
import type { UpdateCredentialsParams } from 'masto'
const { form } = defineModel<{
form: {
fieldsAttributes: NonNullable<UpdateCredentialsParams['fieldsAttributes']>
}
}>()
const dropdown = $ref<any>()
const fieldIcons = computed(() =>
Array.from({ length: 4 }, (_, i) =>
getAccountFieldIcon(form.value.fieldsAttributes[i].name),
),
)
const chooseIcon = (i: number, text: string) => {
form.value.fieldsAttributes[i].name = text
dropdown[i]?.hide()
}
</script>
<template>
<div flex="~ col gap4">
<div v-for="i in 4" :key="i" flex="~ gap3" items-center>
<CommonDropdown ref="dropdown" placement="left">
<CommonTooltip content="Pick a icon">
<button btn-action-icon>
<div :class="fieldIcons[i - 1] || 'i-ri:question-mark'" />
</button>
</CommonTooltip>
<template #popper>
<div flex="~ wrap gap-1" max-w-50 m2>
<CommonTooltip
v-for="(icon, text) in accountFieldIcons"
:key="icon"
:content="text"
>
<template v-if="text !== 'Joined'">
<div btn-action-icon @click="chooseIcon(i - 1, text)">
<div text-xl :class="icon" />
</div>
</template>
</CommonTooltip>
</div>
</template>
</CommonDropdown>
<input
v-model="form.fieldsAttributes[i - 1].name"
type="text"
p2 border-rounded w-full bg-transparent
outline-none border="~ base"
placeholder="Label"
>
<input
v-model="form.fieldsAttributes[i - 1].value"
type="text"
p2 border-rounded w-full bg-transparent
outline-none border="~ base"
placeholder="Content"
>
</div>
</div>
</template>

View file

@ -0,0 +1,42 @@
<script setup lang="ts">
defineProps<{
icon?: string
text?: string
checked: boolean
}>()
</script>
<template>
<button
exact-active-class="text-primary"
block w-full group focus:outline-none
>
<div
w-full flex w-fit px5 py3 md:gap2 gap4 items-center
transition-250 group-hover:bg-active
group-focus-visible:ring="2 current"
>
<div flex-1 flex items-center md:gap2 gap4>
<div
flex items-center justify-center flex-shrink-0
:class="$slots.description ? 'w-12 h-12' : ''"
>
<slot name="icon">
<div v-if="icon" :class="icon" md:text-size-inherit text-xl />
</slot>
</div>
<div space-y-1>
<p :class="checked ? 'text-base' : 'text-secondary'">
<slot>
<span>{{ text }}</span>
</slot>
</p>
<p v-if="$slots.description" text-sm text-secondary>
<slot name="description" />
</p>
</div>
</div>
<div text-lg :class="checked ? 'i-ri-checkbox-line text-primary' : 'i-ri-checkbox-blank-line text-secondary'" />
</div>
</button>
</template>

View file

@ -17,6 +17,10 @@ defineOptions({
inheritAttrs: false,
})
defineSlots<{
text: {}
}>()
const el = ref<HTMLDivElement>()
useCommand({
@ -58,10 +62,14 @@ useCommand({
</div>
</CommonTooltip>
<CommonAnimateNumber v-if="text !== undefined" :increased="active" text-sm>
<span text-secondary-light>{{ text }}</span>
<CommonAnimateNumber v-if="text !== undefined || $slots.text" :increased="active" text-sm>
<span text-secondary-light>
<slot name="text">{{ text }}</slot>
</span>
<template #next>
<span :class="[color]">{{ text }}</span>
<span :class="[color]">
<slot name="text">{{ text }}</slot>
</span>
</template>
</CommonAnimateNumber>
</component>

View file

@ -19,6 +19,8 @@ const {
toggleReblog,
} = $(useStatusActions(props))
const { formatHumanReadableNumber, formatNumber, forSR } = useHumanReadableNumber()
const reply = () => {
if (!checkLogin())
return
@ -42,7 +44,17 @@ const reply = () => {
icon="i-ri:chat-3-line"
:command="command"
@click="reply"
/>
>
<template v-if="status.repliesCount" #text>
<i18n-t keypath="action.reply_count" :plural="status.repliesCount">
<CommonTooltip v-if="forSR(status.repliesCount)" :content="formatNumber(status.repliesCount)" placement="bottom">
<span aria-hidden="true">{{ formatHumanReadableNumber(status.repliesCount) }}</span>
<span sr-only>{{ formatNumber(status.repliesCount) }}</span>
</CommonTooltip>
<span v-else>{{ formatHumanReadableNumber(status.repliesCount) }}</span>
</i18n-t>
</template>
</StatusActionButton>
</div>
<div flex-1>
@ -56,7 +68,17 @@ const reply = () => {
:disabled="isLoading.reblogged"
:command="command"
@click="toggleReblog()"
/>
>
<template v-if="status.reblogsCount" #text>
<i18n-t keypath="action.boost_count" :plural="status.reblogsCount">
<CommonTooltip v-if="forSR(status.reblogsCount)" :content="formatNumber(status.reblogsCount)" placement="bottom">
<span aria-hidden="true">{{ formatHumanReadableNumber(status.reblogsCount) }}</span>
<span sr-only>{{ formatNumber(status.reblogsCount) }}</span>
</CommonTooltip>
<span v-else>{{ formatHumanReadableNumber(status.reblogsCount) }}</span>
</i18n-t>
</template>
</StatusActionButton>
</div>
<div flex-1>
@ -70,7 +92,17 @@ const reply = () => {
:disabled="isLoading.favourited"
:command="command"
@click="toggleFavourite()"
/>
>
<template v-if="status.favouritesCount" #text>
<i18n-t keypath="action.favourite_count" :plural="status.favouritesCount">
<CommonTooltip v-if="forSR(status.favouritesCount)" :content="formatNumber(status.favouritesCount)" placement="bottom">
<span aria-hidden="true">{{ formatHumanReadableNumber(status.favouritesCount) }}</span>
<span sr-only>{{ formatNumber(status.favouritesCount) }}</span>
</CommonTooltip>
<span v-else>{{ formatHumanReadableNumber(status.favouritesCount) }}</span>
</i18n-t>
</template>
</StatusActionButton>
</div>
<div flex-none>

View file

@ -16,6 +16,7 @@ const {
toggleFavourite,
togglePin,
toggleReblog,
toggleMute,
} = $(useStatusActions(props))
const clipboard = useClipboard()
@ -36,11 +37,28 @@ const toggleTranslation = async () => {
isLoading.translation = false
}
const copyLink = async (status: Status) => {
const masto = useMasto()
const getPermalinkUrl = (status: Status) => {
const url = getStatusPermalinkRoute(status)
if (url)
await clipboard.copy(`${location.origin}/${url}`)
return `${location.origin}/${url}`
return null
}
const copyLink = async (status: Status) => {
const url = getPermalinkUrl(status)
if (url)
await clipboard.copy(url)
}
const { share, isSupported: isShareSupported } = useShare()
const shareLink = async (status: Status) => {
const url = getPermalinkUrl(status)
if (url)
await share({ url })
}
const deleteStatus = async () => {
// TODO confirm to delete
if (process.dev) {
@ -50,9 +68,10 @@ const deleteStatus = async () => {
return
}
await useMasto().statuses.remove(status.id)
removeCachedStatus(status.id)
await masto.statuses.remove(status.id)
if (route.name === '@account-status')
if (route.name === 'status')
router.back()
// TODO when timeline, remove this item
@ -67,8 +86,13 @@ const deleteAndRedraft = async () => {
return
}
const { text } = await useMasto().statuses.remove(status.id)
openPublishDialog('dialog', await getDraftFromStatus(status, text), true)
removeCachedStatus(status.id)
await masto.statuses.remove(status.id)
await openPublishDialog('dialog', await getDraftFromStatus(status), true)
// Go to the new status, if the page is the old status
if (lastPublishDialogStatus.value && route.matched.some(m => m.path === '/:server?/@:account/:status'))
router.push(getStatusRoute(lastPublishDialogStatus.value))
}
const reply = () => {
@ -85,12 +109,12 @@ async function editStatus() {
openPublishDialog(`edit-${status.id}`, {
...await getDraftFromStatus(status),
editingStatus: status,
})
}, true)
}
</script>
<template>
<CommonDropdown flex-none ml3 placement="bottom" :eager-mount="command">
<CommonDropdown flex-none ms3 placement="bottom" :eager-mount="command">
<StatusActionButton
:content="$t('action.more')"
color="text-purple"
@ -145,6 +169,23 @@ async function editStatus() {
@click="copyLink(status)"
/>
<CommonDropdownItem
v-if="isShareSupported"
:text="$t('menu.share_post')"
icon="i-ri:share-line"
:command="command"
@click="shareLink(status)"
/>
<CommonDropdownItem
v-if="currentUser && (status.account.id === currentUser.account.id || status.mentions.some(m => m.id === currentUser!.account.id))"
:text="status.muted ? $t('menu.unmute_conversation') : $t('menu.mute_conversation')"
:icon="status.muted ? 'i-ri:eye-line' : 'i-ri:eye-off-line'"
:command="command"
:disabled="isLoading.muted"
@click="toggleMute()"
/>
<NuxtLink :to="status.url" external target="_blank">
<CommonDropdownItem
v-if="status.url"

View file

@ -57,15 +57,36 @@ const type = $computed(() => {
}
return 'unknown'
})
const video = ref<HTMLVideoElement | undefined>()
const prefersReducedMotion = usePreferredReducedMotion()
useIntersectionObserver(video, (entries) => {
if (prefersReducedMotion.value === 'reduce')
return
entries.forEach((entry) => {
if (entry.intersectionRatio <= 0.75)
!video.value!.paused && video.value!.pause()
else
video.value!.play()
})
}, { threshold: 0.75 })
</script>
<template>
<div relative ma flex>
<template v-if="type === 'video'">
<video
ref="video"
preload="none"
:poster="attachment.previewUrl"
muted
loop
playsinline
controls
border="~ base"
rounded-lg
object-cover
:width="attachment.meta?.original?.width"
:height="attachment.meta?.original?.height"
@ -79,10 +100,14 @@ const type = $computed(() => {
</template>
<template v-else-if="type === 'gifv'">
<video
ref="video"
preload="none"
:poster="attachment.previewUrl"
muted
loop
autoplay
playsinline
border="~ base"
rounded-lg
object-cover
:width="attachment.meta?.original?.width"
:height="attachment.meta?.original?.height"
@ -116,7 +141,7 @@ const type = $computed(() => {
:srcset="srcset"
:width="attachment.meta?.original?.width"
:height="attachment.meta?.original?.height"
:alt="attachment.description!"
:alt="attachment.description ?? 'Image'"
:style="{
aspectRatio,
objectPosition,

View file

@ -16,7 +16,7 @@ const { translation } = useTranslation(status)
:emojis="status.emojis"
:lang="status.language"
/>
<div v-else h-3 />
<div v-else />
<template v-if="translation.visible">
<div my2 h-px border="b base" bg-base />
<ContentRich :content="translation.text" :emojis="status.emojis" />

View file

@ -8,7 +8,16 @@ const props = withDefaults(
context?: FilterContext
hover?: boolean
faded?: boolean
showReplyTo?: boolean
// If we know the prev and next status in the timeline, we can simplify the card
older?: Status
newer?: Status
// Manual overrides
hasOlder?: boolean
hasNewer?: boolean
// When looking into a detailed view of a post, we can simplify the replying badges
// to the main expanded post
main?: Status
}>(),
{ actions: true, showReplyTo: true },
)
@ -19,6 +28,11 @@ const status = $computed(() => {
return props.status
})
// Use original status, avoid connecting a reblog
const directReply = $computed(() => props.hasNewer || (!!status.inReplyToId && (status.inReplyToId === props.newer?.id || status.inReplyToId === props.newer?.reblog?.id)))
// Use reblogged status, connect it to further replies
const connectReply = $computed(() => props.hasOlder || status.id === props.older?.inReplyToId || status.id === props.older?.reblog?.inReplyToId)
const rebloggedBy = $computed(() => props.status.reblog ? props.status.account : null)
const el = ref<HTMLElement>()
@ -56,31 +70,59 @@ const filter = $computed(() => filterResult?.filter)
const filterPhrase = $computed(() => filter?.phrase || (filter as any)?.title)
const isFiltered = $computed(() => filterPhrase && (props.context ? filter?.context.includes(props.context) : false))
const avatarOnAvatar = $(computedEager(() => useFeatureFlags().experimentalAvatarOnAvatar))
const showRebloggedByAvatarOnAvatar = $computed(() => rebloggedBy && avatarOnAvatar && rebloggedBy.id !== status.account.id)
const collapseRebloggedBy = $computed(() => rebloggedBy?.id === status.account.id)
// Collapse ReplyingTo badge if it is a self-reply (thread)
const collapseReplyingTo = $computed(() => (!rebloggedBy || collapseRebloggedBy) && status.inReplyToAccountId === status.account.id)
// Only show avatar in ReplyingTo badge if it was reblogged by the same account or if it is against the main post
const simplifyReplyingTo = $computed(() =>
(props.main && props.main.account.id === status.inReplyToAccountId) || (rebloggedBy && rebloggedBy.id === status.inReplyToAccountId),
)
const isDM = $computed(() => status.visibility === 'direct')
</script>
<template>
<div v-if="filter?.filterAction !== 'hide'" :id="`status-${status.id}`" ref="el" relative flex flex-col gap-2 px-4 pt-3 pb-4 transition-100 :class="{ 'hover:bg-active': hover }" tabindex="0" focus:outline-none focus-visible:ring="2 primary" @click="onclick" @keydown.enter="onclick">
<div flex justify-between pb1>
<div
v-if="filter?.filterAction !== 'hide'"
:id="`status-${status.id}`"
ref="el"
relative flex flex-col gap-1 pl-3 pr-4 pt-1
class="pb-1.5"
transition-100
:class="{ 'hover:bg-active': hover, 'border-t border-base': newer && !directReply }"
tabindex="0"
focus:outline-none focus-visible:ring="2 primary"
:lang="status.language ?? undefined"
@click="onclick"
@keydown.enter="onclick"
>
<div flex justify-between>
<slot name="meta">
<div v-if="rebloggedBy" text-secondary text-sm ws-nowrap flex="~" gap-1 items-center>
<div i-ri:repeat-fill mr-1 text-primary />
<AccountInlineInfo font-bold :account="rebloggedBy" :avatar="!avatarOnAvatar" />
<div v-if="rebloggedBy && !collapseRebloggedBy" relative text-secondary ws-nowrap flex="~" items-center pt1 pb0.5 px-1px bg-base>
<div i-ri:repeat-fill me-46px text-primary w-16px h-16px />
<div absolute top-1 ms-24px w-32px h-32px rounded-full>
<AccountAvatar :account="rebloggedBy" />
</div>
<AccountInlineInfo font-bold :account="rebloggedBy" :avatar="false" text-sm />
</div>
<div v-else />
</slot>
<StatusReplyingTo v-if="showReplyTo" :status="status" :class="faded ? 'text-secondary-light' : ''" />
<StatusReplyingTo v-if="!directReply && !collapseReplyingTo" :status="status" :simplified="simplifyReplyingTo" :class="faded ? 'text-secondary-light' : ''" pt1 />
</div>
<div flex gap-4 :class="faded ? 'text-secondary' : ''">
<div flex gap-3 :class="{ 'text-secondary': faded }">
<div relative>
<AccountHoverWrapper :account="status.account" :class="showRebloggedByAvatarOnAvatar ? 'mt-4' : 'mt-1'">
<div v-if="collapseRebloggedBy" absolute flex items-center justify-center top--6px px-2px py-3px rounded-full bg-base>
<div i-ri:repeat-fill text-primary w-16px h-16px />
</div>
<AccountHoverWrapper :account="status.account">
<NuxtLink :to="getAccountRoute(status.account)" rounded-full>
<AccountAvatar w-12 h-12 :account="status.account" />
<AccountBigAvatar :account="status.account" />
</NuxtLink>
</AccountHoverWrapper>
<div v-if="showRebloggedByAvatarOnAvatar" absolute class="-top-2 -left-2" w-9 h-9 border-bg-base border-3 rounded-full>
<AccountAvatar :account="rebloggedBy" />
<div v-if="connectReply" w-full h-full flex justify-center>
<div h-full class="w-2.5px" bg-border />
</div>
</div>
<div flex="~ col 1" min-w-0>
@ -88,58 +130,33 @@ const showRebloggedByAvatarOnAvatar = $computed(() => rebloggedBy && avatarOnAva
<AccountHoverWrapper :account="status.account">
<StatusAccountDetails :account="status.account" />
</AccountHoverWrapper>
<div v-if="!directReply && collapseReplyingTo" flex="~" ps-1 items-center justify-center>
<StatusReplyingTo :collapsed="true" :status="status" :class="faded ? 'text-secondary-light' : ''" />
</div>
<div flex-auto />
<div v-if="!isZenMode" text-sm text-secondary flex="~ row nowrap" hover:underline>
<AccountBotIndicator v-if="status.account.bot" mr-2 />
<CommonTooltip :content="createdAt">
<a :title="status.createdAt" :href="getStatusRoute(status).href" @click.prevent="go($event)">
<time text-sm ws-nowrap hover:underline :datetime="status.createdAt">
{{ timeago }}
</time>
</a>
</CommonTooltip>
<StatusEditIndicator :status="status" inline />
<AccountBotIndicator v-if="status.account.bot" me-2 />
<div flex>
<CommonTooltip :content="createdAt">
<a :title="status.createdAt" :href="getStatusRoute(status).href" @click.prevent="go($event)">
<time text-sm ws-nowrap hover:underline :datetime="status.createdAt">
{{ timeago }}
</time>
</a>
</CommonTooltip>
<StatusEditIndicator :status="status" inline />
</div>
</div>
<StatusActionsMore :status="status" mr--2 />
<StatusActionsMore v-if="actions !== false" :status="status" me--2 />
</div>
<div
space-y-2
:class="{
'my3 p1 px4 br2 bg-fade border-primary-light border-1 rounded-3 rounded-tl-none': status.visibility === 'direct',
}"
>
<StatusSpoiler :enabled="status.sensitive || isFiltered" :filter="isFiltered">
<template v-if="status.spoilerText || filterPhrase" #spoiler>
<p>{{ status.spoilerText || `${$t('status.filter_hidden_phrase')}: ${filterPhrase}` }}</p>
</template>
<StatusBody :status="status" />
<StatusPoll
v-if="status.poll"
:poll="status.poll"
/>
<StatusMedia
v-if="status.mediaAttachments?.length"
:status="status"
:class="status.visibility === 'direct' ? 'pb4' : ''"
/>
<StatusPreviewCard
v-if="status.card"
:card="status.card"
:class="status.visibility === 'direct' ? 'pb4' : ''"
:small-picture-only="status.mediaAttachments?.length > 0"
/>
</StatusSpoiler>
<StatusCard
v-if="status.reblog"
:status="status.reblog" border="~ rounded"
:actions="false"
/>
<StatusContent :status="status" :context="context" mb2 :class="{ 'mt-2 mb1': isDM }" />
<div>
<StatusActions v-if="(actions !== false && !isZenMode)" :status="status" />
</div>
<StatusActions v-if="(actions !== false && !isZenMode)" pt2 :status="status" />
</div>
</div>
</div>
<div v-else-if="isFiltered" gap-2 p-4>
<div v-else-if="isFiltered" gap-2 p-4 :class="{ 'border-t border-base': newer }">
<p text-center text-secondary text-sm>
{{ filterPhrase && `${$t('status.filter_removed_phrase')}: ${filterPhrase}` }}
</p>

View file

@ -0,0 +1,57 @@
<script setup lang="ts">
import type { FilterContext, Status } from 'masto'
const { status, context } = defineProps<{
status: Status
context?: FilterContext | 'details'
}>()
const isDM = $computed(() => status.visibility === 'direct')
const isDetails = $computed(() => context === 'details')
// Content Filter logic
const filterResult = $computed(() => status.filtered?.length ? status.filtered[0] : null)
const filter = $computed(() => filterResult?.filter)
// a bit of a hack due to Filter being different in v1 and v2
// clean up when masto.js supports explicit versions: https://github.com/neet/masto.js/issues/722
const filterPhrase = $computed(() => filter?.phrase || (filter as any)?.title)
const isFiltered = $computed(() => filterPhrase && (context && context !== 'details' ? filter?.context.includes(context) : false))
</script>
<template>
<div
space-y-3
:class="{
'pt2 pb0.5 px3.5 bg-fade border-1 border-primary-light rounded-5 mx--1': isDM,
'ms--3.5 mt--1': isDM && context !== 'details',
}"
>
<StatusBody v-if="!isFiltered && status.sensitive && !status.spoilerText" :status="status" :with-action="!isDetails" :class="isDetails ? 'text-xl' : ''" />
<StatusSpoiler :enabled="status.sensitive || isFiltered" :filter="isFiltered">
<template v-if="filterPhrase" #spoiler>
<p>{{ `${$t('status.filter_hidden_phrase')}: ${filterPhrase}` }}</p>
</template>
<template v-else-if="status.spoilerText" #spoiler>
<p>{{ status.spoilerText }}</p>
</template>
<StatusBody v-if="!status.sensitive || status.spoilerText" :status="status" :with-action="!isDetails" :class="isDetails ? 'text-xl' : ''" />
<StatusPoll v-if="status.poll" :status="status" />
<StatusMedia
v-if="status.mediaAttachments?.length"
:status="status"
/>
<StatusPreviewCard
v-if="status.card"
:card="status.card"
:small-picture-only="status.mediaAttachments?.length > 0"
/>
<StatusCard
v-if="status.reblog"
:status="status.reblog" border="~ rounded"
:actions="false"
/>
<div v-if="isDM" />
</StatusSpoiler>
</div>
</template>

View file

@ -1,10 +1,13 @@
<script setup lang="ts">
import type { Status } from 'masto'
const props = defineProps<{
const props = withDefaults(defineProps<{
status: Status
command?: boolean
}>()
actions?: boolean
}>(), {
actions: true,
})
const status = $computed(() => {
if (props.status.reblog && props.status.reblog)
@ -15,45 +18,25 @@ const status = $computed(() => {
const createdAt = useFormattedDateTime(status.createdAt)
const visibility = $computed(() => STATUS_VISIBILITIES.find(v => v.value === status.visibility)!)
const { t } = useI18n()
useHeadFixed({
title: () => `${status.account.displayName || status.account.acct} ${t('common.in')} ${t('app_name')}: "${removeHTMLTags(status.content) || ''}"`,
})
const isDM = $computed(() => status.visibility === 'direct')
</script>
<template>
<div :id="`status-${status.id}`" flex flex-col gap-2 py3 px-4 relative>
<StatusActionsMore :status="status" absolute right-2 top-2 />
<NuxtLink :to="getAccountRoute(status.account)" rounded-full hover:bg-active transition-100 pr5 mr-a>
<div :id="`status-${status.id}`" flex flex-col gap-2 pt2 pb1 px-4 relative :lang="status.language ?? undefined">
<StatusActionsMore :status="status" absolute inset-ie-2 top-2 />
<NuxtLink :to="getAccountRoute(status.account)" rounded-full hover:bg-active transition-100 pe5 me-a>
<AccountHoverWrapper :account="status.account">
<AccountInfo :account="status.account" />
</AccountHoverWrapper>
</NuxtLink>
<div
:class="status.visibility === 'direct' ? 'my2 p1 px4 br2 bg-fade border-primary-light border-1 rounded-3 rounded-tl-none' : ''"
>
<StatusSpoiler :enabled="status.sensitive">
<template #spoiler>
<p text-2xl>
{{ status.spoilerText }}
</p>
</template>
<StatusBody :status="status" :with-action="false" text-2xl />
<StatusPoll
v-if="status.poll"
:poll="status.poll"
/>
<StatusMedia
v-if="status.mediaAttachments?.length"
:status="status"
:class="status.visibility === 'direct' ? 'pb4' : ''"
full-size
/>
<StatusPreviewCard
v-if="status.card"
:card="status.card"
:class="status.visibility === 'direct' ? 'pb4' : ''"
:small-picture-only="status.mediaAttachments?.length > 0"
mt-2
/>
</StatusSpoiler>
</div>
<StatusContent :status="status" context="details" />
<div flex="~ gap-1" items-center text-secondary text-sm>
<div flex>
<div>{{ createdAt }}</div>
@ -61,7 +44,7 @@ const visibility = $computed(() => STATUS_VISIBILITIES.find(v => v.value === sta
:status="status"
:inline="false"
>
<span ml1 font-bold cursor-pointer>{{ $t('state.edited') }}</span>
<span ms1 font-bold cursor-pointer>{{ $t('state.edited') }}</span>
</StatusEditIndicator>
</div>
<div>&middot;</div>
@ -69,9 +52,14 @@ const visibility = $computed(() => STATUS_VISIBILITIES.find(v => v.value === sta
<div :class="visibility.icon" />
</CommonTooltip>
<div v-if="status.application?.name">
&middot; {{ status.application?.name }}
&middot;
</div>
<div v-if="status.application?.name">
{{ status.application?.name }}
</div>
</div>
<StatusActions :status="status" details :command="command" border="t base" pt-2 />
<div border="t base" pt-2>
<StatusActions v-if="actions" :status="status" details :command="command" />
</div>
</div>
</template>

View file

@ -1,10 +1,10 @@
<script setup lang="ts">
import type { Poll } from 'masto'
import type { Status } from 'masto'
const { poll: _poll } = defineProps<{
poll: Poll
const { status } = defineProps<{
status: Status
}>()
const poll = reactive({ ..._poll })
const poll = reactive({ ...status.poll! })
function toPercentage(num: number) {
const percentage = 100 * num
@ -13,13 +13,12 @@ function toPercentage(num: number) {
const timeAgoOptions = useTimeAgoOptions()
const expiredTimeAgo = useTimeAgo(poll.expiresAt!, timeAgoOptions)
const expiredTimeFormatted = useFormattedDateTime(poll.expiresAt!)
const { formatHumanReadableNumber } = useHumanReadableNumber()
const { formatHumanReadableNumber, formatNumber, formatPercentage, forSR } = useHumanReadableNumber()
const masto = useMasto()
async function vote(e: Event) {
const formData = new FormData(e.target as HTMLFormElement)
const choices = formData.getAll('choices') as string[]
await masto.poll.vote(poll.id, { choices })
// Update the poll optimistically
for (const [index, option] of poll.options.entries()) {
@ -29,11 +28,19 @@ async function vote(e: Event) {
poll.voted = true
poll.votesCount++
poll.votersCount = (poll.votersCount || 0) + 1
cacheStatus({ ...status, poll }, undefined, true)
await masto.poll.vote(poll.id, { choices })
}
const votersCount = $computed(() => poll.votersCount ?? 0)
const votersCountHR = $computed(() => formatHumanReadableNumber(votersCount))
const votersCountNumber = $computed(() => formatNumber(votersCount))
const votersCountSR = $computed(() => forSR(votersCount))
</script>
<template>
<div flex flex-col w-full items-stretch gap-3>
<div flex flex-col w-full items-stretch gap-3 dir="auto">
<form v-if="!poll.voted && !poll.expired" flex flex-col gap-4 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>
<input name="choices" :value="index" :type="poll.multiple ? 'checkbox' : 'radio'">
@ -48,17 +55,23 @@ async function vote(e: Event) {
<div flex justify-between pb-2 w-full>
<span inline-flex align-items>
{{ option.title }}
<span v-if="poll.voted && poll.ownVotes?.includes(index)" ml-2 mt-1 inline-block i-ri:checkbox-circle-line />
<span v-if="poll.voted && poll.ownVotes?.includes(index)" ms-2 mt-1 inline-block i-ri:checkbox-circle-line />
</span>
<span text-primary-active> {{ poll.votesCount ? toPercentage((option.votesCount || 0) / (poll.votesCount)) : '0%' }}</span>
<span text-primary-active> {{ formatPercentage(votersCount > 0 ? (option.votesCount || 0) / votersCount : 0) }}</span>
</div>
<div class="bg-gray/40" rounded-l-sm rounded-r-lg h-5px w-full>
<div bg-primary-active h-full class="w-[var(--bar-width)]" />
</div>
</div>
</template>
<div text-sm>
{{ $t('status.poll.count', formatHumanReadableNumber(poll.votersCount ?? 0)) }}
<div text-sm flex="~ inline" gap-x-1>
<i18n-t keypath="status.poll.count" :plural="votersCount">
<CommonTooltip v-if="votersCountSR" :content="votersCountNumber" placement="bottom">
<span aria-hidden="true">{{ votersCountHR }}</span>
<span sr-only>{{ votersCountNumber }}</span>
</CommonTooltip>
<span v-else>{{ votersCountNumber }}</span>
</i18n-t>
&middot;
<CommonTooltip :content="expiredTimeFormatted" class="inline-block" placement="right">
<time :datetime="poll.expiresAt!">{{ $t(poll.expired ? 'status.poll.finished' : 'status.poll.ends', [expiredTimeAgo]) }}</time>

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { Card } from 'masto'
import type { Card, CardType } from 'masto'
const props = defineProps<{
card: Card
@ -27,6 +27,12 @@ const gitHubCards = $(computedEager(() => useFeatureFlags().experimentalGitHubCa
const isMastodonLink = true
// TODO: handle card.type: 'photo' | 'video' | 'rich';
const cardTypeIconMap: Record<CardType, string> = {
link: 'i-ri:profile-line',
photo: 'i-ri:image-line',
video: 'i-ri:play-line',
rich: 'i-ri:profile-line',
}
</script>
<template>
@ -72,7 +78,7 @@ const isMastodonLink = true
root ? 'rounded-lg' : '',
]"
>
<div i-ri:profile-line w="30%" h="30%" text-secondary />
<div :class="cardTypeIconMap[card.type]" w="30%" h="30%" text-secondary />
</div>
<StatusPreviewCardInfo :root="root" :card="card" :provider="providerName" :is-square="isSquare" />
</NuxtLink>

View file

@ -8,43 +8,59 @@ const props = defineProps<{
type UrlType = 'user' | 'repo' | 'issue' | 'pull'
interface Meta {
type: UrlType
user: string
user?: string
titleUrl: string
avatar: string
details: string
repo?: string
number?: string
extra?: {
state: string
author?: {
avatar: string
user: string
}
author?: {
avatar: string
user: string
}
}
const meta = $computed(() => {
const { url } = props.card
const path = url.split('https://github.com/')[1]
const user = path.match(/([\w-]+)\//)![1]
const repo = path.match(/[\w-]+\/([\w-]+)/)?.[1]
const repoPath = `${user}/${repo}`
const inRepoPath = path.split(`${repoPath}/`)?.[1]
let number: string | undefined
// Supported paths
// /user
// /user/repo
// /user/repo/issues/number.*
// /user/repo/pull/number.*
// /orgs/user.*
const firstName = path.match(/([\w-]+)(\/|$)/)?.[1]
const secondName = path.match(/[\w-]+\/([\w-]+)/)?.[1]
const firstIsUser = firstName !== 'orgs' && firstName !== 'sponsors'
const user = firstIsUser ? firstName : secondName
const repo = firstIsUser ? secondName : undefined
let type: UrlType = repo ? 'repo' : 'user'
if (inRepoPath) {
number = inRepoPath.match(/issues\/(\d+)/)?.[1]
if (number) {
type = 'issue'
}
else {
number = inRepoPath.match(/pull\/(\d+)/)?.[1]
if (number)
type = 'pull'
let number: string | undefined
let details = (props.card.title ?? '').replace('GitHub - ', '').split(' · ')[0]
if (repo) {
const repoPath = `${user}/${repo}`
details = details.replace(`${repoPath}: `, '')
const inRepoPath = path.split(`${repoPath}/`)?.[1]
if (inRepoPath) {
number = inRepoPath.match(/issues\/(\d+)/)?.[1]
if (number) {
type = 'issue'
}
else {
number = inRepoPath.match(/pull\/(\d+)/)?.[1]
if (number)
type = 'pull'
}
}
}
const avatar = `https://github.com/${user}.png`
const details = (props.card.title ?? '').replace('GitHub - ', '').replace(`${repoPath}: `, '').split(' · ')[0]
const avatar = `https://github.com/${user}.png?size=256`
const author = props.card.authorName
const info = $ref<Meta>({
type,
user,
@ -53,23 +69,13 @@ const meta = $computed(() => {
repo,
number,
avatar,
author: author
? {
avatar: `https://github.com/${author}.png?size=64`,
user: author,
}
: undefined,
})
/* It is rate limited for anonymous usage, leaving this to play, but for now it is probably better to avoid the call
We can't show the author of the PR or issue without this info, because the handle isn't in the meta. I think we
could ask GitHub to add it.
if (number) {
fetch(`https://api.github.com/repos/${user}/${repo}/issues/${number}`).then(res => res.json()).then((data) => {
info.extra = {
state: data.state as string,
author: {
avatar: data.user.avatar_url as string,
user: data.user.login as string,
},
}
})
}
*/
return info
})
</script>
@ -96,10 +102,10 @@ const meta = $computed(() => {
<span v-else>{{ meta.user }}</span>
</a>
<a sm:text-lg :href="card.url" target="_blank">
<span v-if="meta.type === 'issue'" text-secondary-light mr-2>
<span v-if="meta.type === 'issue'" text-secondary-light me-2>
#{{ meta.number }}
</span>
<span v-if="meta.type === 'pull'" text-secondary-light mr-2>
<span v-if="meta.type === 'pull'" text-secondary-light me-2>
PR #{{ meta.number }}
</span>
<span text-secondary leading-tight>{{ meta.details }}</span>
@ -112,14 +118,14 @@ const meta = $computed(() => {
</div>
</div>
<div flex justify-between>
<div v-if="meta.extra" flex gap-2 items-center>
<div v-if="meta.author" flex class="gap-2.5" items-center>
<div>
<img w-6 aspect-square width="20" height="20" rounded-full :src="meta.extra?.author?.avatar">
<img w-8 aspect-square width="25" height="25" rounded-full :src="meta.author?.avatar">
</div>
<span text-xl text-primary font-bold>@{{ meta.extra?.author?.user }}</span>
<span text-lg text-primary>@{{ meta.author?.user }}</span>
</div>
<div v-else />
<div text-2xl i-ri:github-fill text-secondary />
<div text-3xl i-ri:github-fill text-secondary />
</div>
</div>
</div>

View file

@ -1,27 +1,31 @@
<script setup lang="ts">
import type { Status } from 'masto'
const { status } = defineProps<{
const { status, collapsed = false, simplified = false } = defineProps<{
status: Status
collapsed?: boolean
simplified?: boolean
}>()
const account = useAccountById(status.inReplyToAccountId)
const isSelf = $computed(() => status.inReplyToAccountId === status.account.id)
const account = isSelf ? computed(() => status.account) : useAccountById(status.inReplyToAccountId)
</script>
<template>
<div v-if="status.inReplyToAccountId" flex="~ wrap" gap-1>
<div v-if="status.inReplyToAccountId" flex="~ wrap" gap-1 items-end>
<NuxtLink
v-if="status.inReplyToId"
flex="~" items-center font-bold text-sm text-secondary gap-1
flex="~" items-center h-auto font-bold text-sm text-secondary gap-1
:to="getStatusInReplyToRoute(status)"
:title="account ? `Replying to ${getDisplayName(account)}` : 'Replying to someone'"
>
<div i-ri:reply-fill class="scale-x-[-1]" text-secondary-light />
<template v-if="account?.id !== status.account.id">
<AccountInlineInfo v-if="account" :account="account" :link="false" />
<span v-else ws-nowrap>{{ $t('status.someone') }}</span>
<template v-if="account">
<div i-ri:reply-fill :class="collapsed ? '' : 'scale-x-[-1]'" text-secondary-light />
<template v-if="!collapsed">
<AccountAvatar v-if="isSelf || simplified || status.inReplyToAccountId === currentUser?.account.id" :account="account" :link="false" w-5 h-5 mx-0.5 />
<AccountInlineInfo v-else :account="account" :link="false" mx-0.5 />
</template>
</template>
<span v-else ws-nowrap>{{ $t('status.thread') }}</span>
<div i-ph:chats-fill text-primary text-lg />
</NuxtLink>
</div>

View file

@ -11,11 +11,11 @@ watchEffect(() => {
<template>
<div v-if="enabled" flex flex-col items-start>
<div class="content-rich" px-4 pb-2 text-center text-secondary text-sm w-full border="~ base" border-0 border-b-dotted border-b-3 mt-2>
<div class="content-rich" px-4 pb-2.5 text-center text-secondary w-full border="~ base" border-0 border-b-dotted border-b-3 mt-2>
<slot name="spoiler" />
</div>
<div flex="~ gap-1 center" w-full mt="-3.5">
<button btn-text px-2 py-1 text-3 bg-base flex="~ center gap-2" :class="showContent ? '' : 'filter-saturate-0 hover:filter-saturate-100'" @click="toggleContent()">
<div flex="~ gap-1 center" w-full mt="-4.5">
<button btn-text px-2 py-1 bg-base flex="~ center gap-2" :class="showContent ? '' : 'filter-saturate-0 hover:filter-saturate-100'" @click="toggleContent()">
<div v-if="showContent" i-ri:eye-line />
<div v-else i-ri:eye-close-line />
{{ showContent ? $t('status.spoiler_show_less') : $t(filter ? 'status.filter_show_anyway' : 'status.spoiler_show_more') }}

View file

@ -1,11 +1,13 @@
<script setup lang="ts">
import type { Status, StatusEdit } from 'masto'
import { formatTimeAgo } from '@vueuse/core'
const { status } = defineProps<{
status: Status
}>()
const { data: statusEdits } = useAsyncData(`status:history:${status.id}`, () => useMasto().statuses.fetchHistory(status.id).then(res => res.reverse()))
const masto = useMasto()
const { data: statusEdits } = useAsyncData(`status:history:${status.id}`, () => masto.statuses.fetchHistory(status.id).then(res => res.reverse()))
const showHistory = (edit: StatusEdit) => {
openEditHistoryDialog(edit)
@ -22,9 +24,17 @@ const timeAgoOptions = useTimeAgoOptions()
@click="showHistory(edit)"
>
{{ getDisplayName(edit.account) }}
<i18n-t :keypath="`status_history.${idx === statusEdits.length - 1 ? 'created' : 'edited'}`">
{{ useTimeAgo(edit.createdAt, timeAgoOptions).value }}
</i18n-t>
<template v-if="idx === statusEdits.length - 1">
<i18n-t keypath="status_history.created">
{{ formatTimeAgo(new Date(edit.createdAt), timeAgoOptions) }}
</i18n-t>
</template>
<template v-else>
<i18n-t keypath="status_history.edited">
{{ formatTimeAgo(new Date(edit.createdAt), timeAgoOptions) }}
</i18n-t>
</template>
</CommonDropdownItem>
</template>
<template v-else>

View file

@ -25,10 +25,10 @@ const toggleFollowTag = async () => {
<button
rounded group focus:outline-none
hover:text-primary focus-visible:text-primary
:aria-label="tag.following ? `Unfollow ${tag.name} tag` : `Follow ${tag.name} tag`"
:aria-label="tag.following ? $t('tag.unfollow_label', [tag.name]) : $t('tag.follow_label', [tag.name])"
@click="toggleFollowTag()"
>
<CommonTooltip placement="bottom" :content="tag.following ? 'Unfollow' : 'Follow'">
<CommonTooltip placement="bottom" :content="tag.following ? $t('tag.unfollow') : $t('tag.follow')">
<div rounded-full p2 group-hover="bg-orange/10" group-focus-visible="bg-orange/10" group-focus-visible:ring="2 current">
<div :class="[tag.following ? 'i-ri:star-fill' : 'i-ri:star-line']" />
</div>

View file

@ -0,0 +1,7 @@
<script setup lang="ts">
const paginator = useMasto().blocks.iterate()
</script>
<template>
<AccountPaginator :paginator="paginator" />
</template>

View file

@ -0,0 +1,7 @@
<script setup lang="ts">
const paginator = useMasto().bookmarks.iterate()
</script>
<template>
<TimelinePaginator :paginator="paginator" />
</template>

View file

@ -0,0 +1,7 @@
<script setup lang="ts">
const paginator = useMasto().conversations.iterate()
</script>
<template>
<ConversationPaginator :paginator="paginator" />
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
const masto = useMasto()
const paginator = masto.domainBlocks.iterate()
const unblock = async (domain: string) => {
await masto.domainBlocks.unblock(domain)
}
</script>
<template>
<CommonPaginator :paginator="paginator">
<template #default="{ item }">
<CommonDropdownItem class="!cursor-auto">
{{ item }}
<template #actions>
<div i-ri:lock-unlock-line text-primary cursor-pointer @click="unblock(item)" />
</template>
</CommonDropdownItem>
</template>
</CommonPaginator>
</template>

View file

@ -0,0 +1,7 @@
<script setup lang="ts">
const paginator = useMasto().favourites.iterate()
</script>
<template>
<TimelinePaginator :paginator="paginator" />
</template>

View file

@ -0,0 +1,13 @@
<script setup lang="ts">
import type { Status } from 'masto'
const paginator = useMasto().timelines.iterateHome()
const stream = useMasto().stream.streamUser()
onBeforeUnmount(() => stream?.then(s => s.disconnect()))
</script>
<template>
<div>
<PublishWidget draft-key="home" border="b base" />
<TimelinePaginator v-bind="{ paginator, stream }" :preprocess="timelineWithReorderedReplies" context="home" />
</div>
</template>

View file

@ -0,0 +1,13 @@
<script setup lang="ts">
// Default limit is 20 notifications, and servers are normally caped to 30
const paginator = useMasto().notifications.iterate({ limit: 30, types: ['mention'] })
const { clearNotifications } = useNotifications()
onActivated(clearNotifications)
const stream = useMasto().stream.streamUser()
</script>
<template>
<NotificationPaginator v-bind="{ paginator, stream }" />
</template>

View file

@ -0,0 +1,7 @@
<script setup lang="ts">
const paginator = useMasto().mutes.iterate()
</script>
<template>
<AccountPaginator :paginator="paginator" />
</template>

View file

@ -0,0 +1,13 @@
<script setup lang="ts">
// Default limit is 20 notifications, and servers are normally caped to 30
const paginator = useMasto().notifications.iterate({ limit: 30 })
const { clearNotifications } = useNotifications()
onActivated(clearNotifications)
const stream = useMasto().stream.streamUser()
</script>
<template>
<NotificationPaginator v-bind="{ paginator, stream }" />
</template>

View file

@ -6,34 +6,31 @@ import type { FilterContext, Paginator, Status, WsEvents } from 'masto'
const { paginator, stream } = defineProps<{
paginator: Paginator<any, Status[]>
stream?: WsEvents
stream?: Promise<WsEvents>
context?: FilterContext
preprocess?: (items: any[]) => any[]
}>()
const { formatNumber } = useHumanReadableNumber()
const virtualScroller = $(computedEager(() => useFeatureFlags().experimentalVirtualScroll))
</script>
<template>
<CommonPaginator v-bind="{ paginator, stream }" :virtual-scroller="virtualScroller">
<CommonPaginator v-bind="{ paginator, stream, preprocess }" :virtual-scroller="virtualScroller">
<template #updater="{ number, update }">
<button py-4 border="b base" flex="~ col" p-3 w-full text-primary font-bold @click="update">
{{ $t('timeline.show_new_items', number) }}
{{ $t('timeline.show_new_items', number, { named: { v: formatNumber(number) } }) }}
</button>
</template>
<template #default="{ item, active }">
<template #default="{ item, older, newer, active }">
<template v-if="virtualScroller">
<DynamicScrollerItem :item="item" :active="active" tag="article">
<StatusCard :status="item" border="b base" :context="context" />
<StatusCard :status="item" :context="context" :older="older" :newer="newer" />
</DynamicScrollerItem>
</template>
<template v-else>
<StatusCard :status="item" border="b base" :context="context" />
<StatusCard :status="item" :context="context" :older="older" :newer="newer" />
</template>
</template>
<template #loading>
<StatusCardSkeleton border="b base" />
<StatusCardSkeleton border="b base" op50 />
<StatusCardSkeleton border="b base" op25 />
</template>
</CommonPaginator>
</template>

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