Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e75b64a
feat: start work on profile editing frontend.
purifetchi Jun 27, 2025
6d4fe09
feat: handle parsing profile record on backend and style front.
purifetchi Jun 29, 2025
e7abebd
feat: implement pushing the profile to the repo.
purifetchi Jul 7, 2025
e54d58f
feat: start working on showing profile pics in oekaki cards.
purifetchi Jul 7, 2025
9966ad8
feat: implement a better avatar component.
purifetchi Jul 8, 2025
e4fb19b
feat: show avatars on child cards.
purifetchi Jul 23, 2025
dcf91e3
feat: add validation to profile records.
purifetchi Jul 25, 2025
b757341
feat: validate putOekaki
purifetchi Jul 25, 2025
72abacd
feat: translations.
purifetchi Jul 25, 2025
e45e5ed
feat: allow unsetting an avatar.
purifetchi Jul 25, 2025
ea6e0fd
feat: use the new default pfp drawn by domatoxi.
purifetchi Jul 26, 2025
0965f84
fix: trim whitespace from handles.
purifetchi Jul 26, 2025
b8b954f
feat: login on enter keydown.
purifetchi Jul 27, 2025
e9f61aa
feat: backfill profiles.
purifetchi Jul 27, 2025
ceb3145
feat: have the link to a reply actually point to the parent.
purifetchi Jul 27, 2025
b3fad37
feat: allow linking to profiles and posts via handle.
purifetchi Jul 27, 2025
fa364d6
feat: translate the no description blurb.
purifetchi Jul 27, 2025
0b874bf
feat: implement going back to the editor before uploading.
purifetchi Jul 27, 2025
fa3f792
feat: do not center everything in mobile view.
purifetchi Jul 28, 2025
ab39a0f
docs: credit domatoxi for the PinkSea-tan avatar.
purifetchi Jul 28, 2025
049f8ca
chore: remove port mapping for postgres.
purifetchi Jul 28, 2025
353ae8d
feat: implement profile backfill for existing databases.
purifetchi Jul 28, 2025
bee2b6d
feat: set limits in the edit ui.
purifetchi Jul 28, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions PinkSea.AtProto.Shared/Lexicons/AtProto/ResolveHandleRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;

namespace PinkSea.AtProto.Shared.Lexicons.AtProto;

/// <summary>
/// The "com.atproto.identity.resolveHandle" request.
/// Resolves an atproto handle (hostname) to a DID. Does not necessarily bi-directionally verify against the the DID document.
/// </summary>
public class ResolveHandleRequest
{
/// <summary>
/// The handle to resolve.
/// </summary>
[JsonPropertyName("handle")]
public required string Handle { get; set; }
}
15 changes: 15 additions & 0 deletions PinkSea.AtProto.Shared/Lexicons/AtProto/ResolveHandleResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System.Text.Json.Serialization;

namespace PinkSea.AtProto.Shared.Lexicons.AtProto;

/// <summary>
/// The response for the "com.atproto.identity.resolveHandle" request.
/// </summary>
public class ResolveHandleResponse
{
/// <summary>
/// The DID of the handle.
/// </summary>
[JsonPropertyName("did")]
public required string Did { get; set; }
}
59 changes: 59 additions & 0 deletions PinkSea.AtProto/Helpers/AtLinkHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
namespace PinkSea.AtProto.Helpers;

/// <summary>
/// Utilities for working with at:// URIs used by the AT Protocol.
/// </summary>
public class AtLinkHelper
{
private const string SchemePrefix = "at://";

/// <summary>Strongly-typed view of an AT Proto URI.</summary>
public readonly record struct AtUri(string Authority, string Collection, string RecordKey)
{
public override string ToString() => $"{SchemePrefix}{Authority}/{Collection}/{RecordKey}";
}

/// <summary>
/// Parse a raw <c>at://</c> string and throw if it is invalid.
/// </summary>
public static AtUri Parse(string value)
{
if (!TryParse(value, out var result))
throw new FormatException($"Invalid AT URI: \"{value}\"");
return result;
}

/// <summary>
/// Try-parse variant that never throws.
/// </summary>
public static bool TryParse(string value, out AtUri result)
{
result = default;

if (string.IsNullOrWhiteSpace(value) ||
!value.StartsWith(SchemePrefix, StringComparison.OrdinalIgnoreCase))
return false;

// work with spans to avoid allocations
ReadOnlySpan<char> span = value.AsSpan(SchemePrefix.Length);

// authority = everything up to first '/'
int firstSlash = span.IndexOf('/');
if (firstSlash <= 0) return false;
var authority = span[..firstSlash].ToString();

span = span[(firstSlash + 1)..]; // skip first '/'
int secondSlash = span.IndexOf('/');
if (secondSlash <= 0 || secondSlash == span.Length - 1) return false;

var collection = span[..secondSlash].ToString();
var recordKey = span[(secondSlash + 1)..].ToString();

// basic sanity
if (collection.Length == 0 || recordKey.Length == 0)
return false;

result = new AtUri(authority, collection, recordKey);
return true;
}
}
9 changes: 8 additions & 1 deletion PinkSea.AtProto/Xrpc/Client/DefaultXrpcClientFactory.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Microsoft.Extensions.Logging;
using PinkSea.AtProto.Http;
using PinkSea.AtProto.Models.Authorization;
using PinkSea.AtProto.Models.OAuth;
using PinkSea.AtProto.OAuth;
using PinkSea.AtProto.Providers.OAuth;
using PinkSea.AtProto.Providers.Storage;
Expand All @@ -24,6 +25,12 @@
if (oauthState?.AuthorizationCode is null)
return null;

return await GetForOAuthState(oauthState);
}

/// <inheritdoc />
public async Task<IXrpcClient?> GetForOAuthState(OAuthState oauthState)

Check warning on line 32 in PinkSea.AtProto/Xrpc/Client/DefaultXrpcClientFactory.cs

View workflow job for this annotation

GitHub Actions / build

This async method lacks 'await' operators and will run synchronously. Consider using the 'await' operator to await non-blocking API calls, or 'await Task.Run(...)' to do CPU-bound work on a background thread.

Check warning on line 32 in PinkSea.AtProto/Xrpc/Client/DefaultXrpcClientFactory.cs

View workflow job for this annotation

GitHub Actions / build

This async method lacks 'await' operators and will run synchronously. Consider using the 'await' operator to await non-blocking API calls, or 'await Task.Run(...)' to do CPU-bound work on a background thread.
{
var xrpcLogger = loggerFactory.CreateLogger<IXrpcClient>();
var httpClient = httpClientFactory.CreateClient("xrpc-client");

Expand All @@ -36,7 +43,7 @@
clientDataProvider.ClientData,
dpopClientLogger);

dpopClient.SetAuthorizationCode(oauthState.AuthorizationCode);
dpopClient.SetAuthorizationCode(oauthState.AuthorizationCode!);
return new DPopXrpcClient(dpopClient, oauthState, xrpcLogger);
}

Expand Down
9 changes: 9 additions & 0 deletions PinkSea.AtProto/Xrpc/Client/IXrpcClientFactory.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using PinkSea.AtProto.Models.OAuth;

namespace PinkSea.AtProto.Xrpc.Client;

/// <summary>
Expand All @@ -11,6 +13,13 @@ public interface IXrpcClientFactory
/// <param name="stateId">The state id.</param>
/// <returns>The XRPC client.</returns>
Task<IXrpcClient?> GetForOAuthStateId(string stateId);

/// <summary>
/// Gets an XRPC client for an oauth state.
/// </summary>
/// <param name="oauthState">The state.</param>
/// <returns>The XRPC client.</returns>
Task<IXrpcClient?> GetForOAuthState(OAuthState oauthState);

/// <summary>
/// Gets an XRPC client without any kind of authentication.
Expand Down
5 changes: 4 additions & 1 deletion PinkSea.Frontend/.vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,8 @@
"source.fixAll": "explicit"
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[typescript]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
}
}
Binary file added PinkSea.Frontend/public/assets/img/blank_avatar.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 23 additions & 0 deletions PinkSea.Frontend/src/api/atproto/handle-did-route-resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { Router } from 'vue-router'
import { xrpc } from './client';

export const withHandleDidRouteResolver = (router: Router): void => {
router.beforeEach(async (to, from) => {
if (to.params.did === undefined) {
return
}

const routeDid = to.params.did as string
if (routeDid.startsWith("did:")) {
return
}

try {
const { data } = await xrpc.get("com.atproto.identity.resolveHandle", { params: { handle: routeDid } })

to.params.did = data.did
} catch {
return
}
});
};
41 changes: 27 additions & 14 deletions PinkSea.Frontend/src/api/atproto/lexicons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Oekaki } from '@/models/oekaki'
import type { SearchType } from '@/models/search-type'
import type { Author } from '@/models/author'
import type { TagSearchResult } from '@/models/tag-search-result'
import type Profile from '@/models/profile'

declare module '@atcute/client/lexicons' {
type EmptyParams = object
Expand Down Expand Up @@ -37,6 +38,26 @@ declare module '@atcute/client/lexicons' {
}
}

// eslint-disable-next-line @typescript-eslint/no-namespace
namespace ComShinolabsPinkseaPutProfile {
interface Input {
profile: {
nickname?: string | null,
bio?: string | null,
avatar?: {
uri: string,
cid: string
},
links?: {
link: string,
name: string
}[]
}
}
interface Output {
}
}

// eslint-disable-next-line @typescript-eslint/no-namespace
namespace ComShinolabsPinkseaDeleteOekaki {
interface Input {
Expand Down Expand Up @@ -109,18 +130,6 @@ declare module '@atcute/client/lexicons' {
interface Params {
did: string
}

interface Output {
did: string,
handle: string,
nick: string,
description: string,
links: [{
name: string,
url: string
}],
avatar: string
}
}

// eslint-disable-next-line @typescript-eslint/no-namespace
Expand Down Expand Up @@ -172,9 +181,9 @@ declare module '@atcute/client/lexicons' {
params: ComShinolabsPinkseaGetParentForReply.Params,
output: ComShinolabsPinkseaGetParentForReply.Params
},
'com.shinolabs.pinksea.unspecced.getProfile': {
'com.shinolabs.pinksea.getProfile': {
params: ComShinolabsPinkseaUnspeccedGetProfile.Params,
output: ComShinolabsPinkseaUnspeccedGetProfile.Output
output: Profile
},
'com.shinolabs.pinksea.getSearchResults': {
params: ComShinolabsPinkseaGetSearchResults.Params,
Expand All @@ -187,6 +196,10 @@ declare module '@atcute/client/lexicons' {
input: ComShinolabsPinkseaPutOekaki.Input,
output: ComShinolabsPinkseaPutOekaki.Output
},
'com.shinolabs.pinksea.putProfile': {
input: ComShinolabsPinkseaPutProfile.Input,
output: ComShinolabsPinkseaPutProfile.Output
},
'com.shinolabs.pinksea.deleteOekaki': {
input: ComShinolabsPinkseaDeleteOekaki.Input,
output: EmptyParams
Expand Down
28 changes: 17 additions & 11 deletions PinkSea.Frontend/src/components/BreadCrumbBar.vue
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
<script setup lang="ts">
import { useBreadcrumbBarStore } from '@/api/breadcrumb/store'
import { resolveCrumb } from '@/api/breadcrumb/breadcrumb'
import { onBeforeMount, ref } from 'vue'
import i18next from 'i18next'
import { useBreadcrumbBarStore } from '@/api/breadcrumb/store'
import { resolveCrumb } from '@/api/breadcrumb/breadcrumb'
import { onBeforeMount, ref } from 'vue'
import i18next from 'i18next'

const forceRerenderKey = ref<number>(0);
const forceRerenderKey = ref<number>(0);

const store = useBreadcrumbBarStore();
const store = useBreadcrumbBarStore();

onBeforeMount(() => {
i18next.on('languageChanged', () => {
forceRerenderKey.value += 1
});
})
onBeforeMount(() => {
i18next.on('languageChanged', () => {
forceRerenderKey.value += 1
});
})
</script>

<template>
Expand Down Expand Up @@ -57,4 +57,10 @@
.bar-current {
font-weight: bold;
}

@media (max-width: 768px) {
.bar {
text-align: center;
}
}
</style>
10 changes: 5 additions & 5 deletions PinkSea.Frontend/src/components/LoginBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,13 @@ const beginOAuth = async () => {

<template>
<div>
<input type="text" :placeholder="i18next.t('menu.input_placeholder')" v-model="handle">
<input type="password" :placeholder="i18next.t('menu.password')" :title="i18next.t('menu.oauth2_info')" v-model="password">
<input type="text" v-on:keydown.enter="beginOAuth" :placeholder="i18next.t('menu.input_placeholder')"
v-model="handle">
<input type="password" v-on:keydown.enter="beginOAuth" :placeholder="i18next.t('menu.password')"
:title="i18next.t('menu.oauth2_info')" v-model="password">
<br />
<button v-on:click.prevent="beginOAuth" ref="login-button">{{ $t("menu.atp_login") }}</button>
</div>
</template>

<style scoped>

</style>
<style scoped></style>
20 changes: 16 additions & 4 deletions PinkSea.Frontend/src/components/RespondBox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ const reply = () => {
}
};

const edit = () => {
Tegaki.open({
onDone: () => {
image.value = Tegaki.flatten().toDataURL("image/png");
}
})
}

const cancel = () => {
image.value = null;

Expand Down Expand Up @@ -90,8 +98,7 @@ const uploadImage = async () => {

onBeforeMount(() => {
if (imageStore.lastReplyId == props.parent.at
&& imageStore.lastDoneReply !== null && imageStore.lastReplyErrored)
{
&& imageStore.lastDoneReply !== null && imageStore.lastReplyErrored) {
image.value = imageStore.lastDoneReply;
}
})
Expand All @@ -106,14 +113,15 @@ onBeforeMount(() => {
<button v-on:click.prevent="reply">{{ $t("response_box.open_painter") }}</button>
</div>
<div v-else>
<img :src="image"/>
<img :src="image" />
<br />
<div class="respond-extra">
<input type="text" :placeholder="i18next.t('painter.add_a_description')" v-model="alt" />
<span><input type="checkbox" v-model="nsfw" /><span>NSFW</span></span>
</div>
<div class="two-buttons">
<button v-on:click.prevent="cancel">{{ $t("response_box.cancel") }}</button>
<button v-on:click.prevent="edit">{{ $t("response_box.edit") }}</button>
<button v-on:click.prevent="uploadImage" ref="upload-button">{{ $t("response_box.reply") }}</button>
</div>
</div>
Expand Down Expand Up @@ -149,6 +157,10 @@ img {
}

.two-buttons {
display: flex;
display: flex;
}

input[type=checkbox] {
accent-color: #FFB6C1;
}
</style>
Loading
Loading