Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public async Task<ErrorOr<string>> LoginWithPassword(string handle, string passw
if (identifier is null)
return ErrorOr<string>.Fail($"Could not resolve the DID for {handle}.");

var didDocument = await didResolver.GetDidResponseForDid(identifier);
var didDocument = await didResolver.GetDocumentForDid(identifier);
if (didDocument is null)
return ErrorOr<string>.Fail($"Could not fetch the DID document for {identifier}.");

Expand Down
58 changes: 58 additions & 0 deletions PinkSea.AtProto/Models/Did/DidDocument.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using System.Text.Json;
using System.Text.Json.Serialization;

namespace PinkSea.AtProto.Models.Did;

/// <summary>
/// A DID document.
/// </summary>
public class DidDocument
{
/// <summary>
/// The JSON-LD context.
/// </summary>
[JsonPropertyName("@context")]
public IReadOnlyList<string>? Context { get; init; }

/// <summary>
/// The ID of the document.
/// </summary>
[JsonPropertyName("id")]
public required string Id { get; init; }

/// <summary>
/// The services operating for this DID.
/// </summary>
[JsonPropertyName("service")]
public required IReadOnlyList<DidService> Services { get; init; }

/// <summary>
/// Other names this DID is known as.
/// </summary>
[JsonPropertyName("alsoKnownAs")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList<string>? AlsoKnownAs { get; init; }

/// <summary>
/// The verification methods.
/// </summary>
[JsonPropertyName("verificationMethods")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList<DidVerificationMethod>? VerificationMethods { get; init; }

/// <summary>
/// Gets the PDS for this Did.
/// </summary>
/// <returns>The address of the PDS.</returns>
public string? GetPds() => Services
.FirstOrDefault(s => s.Id == "#atproto_pds")?
.ServiceEndpoint;

/// <summary>
/// Gets the handle from the AKA field.
/// </summary>
/// <returns>The handle.</returns>
public string? GetHandle() => AlsoKnownAs?
.FirstOrDefault(aka => aka.StartsWith("at://"))?
.Replace("at://", "");
}
26 changes: 0 additions & 26 deletions PinkSea.AtProto/Models/Did/DidResponse.cs

This file was deleted.

2 changes: 1 addition & 1 deletion PinkSea.AtProto/OAuth/AtProtoOAuthClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public async Task<ErrorOr<string>> BeginOAuthFlow(
if (did is null)
return ErrorOr<string>.Fail($"Could not resolve the DID for {handle}");

var resolved = await didResolver.GetDidResponseForDid(did!);
var resolved = await didResolver.GetDocumentForDid(did!);
var pds = resolved?.GetPds();
if (pds is null)
return ErrorOr<string>.Fail($"Could not resolve the PDS for {did}");
Expand Down
17 changes: 8 additions & 9 deletions PinkSea.AtProto/Resolvers/Did/DidResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public class DidResolver(
IMemoryCache memoryCache) : IDidResolver
{
/// <inheritdoc />
public async Task<DidResponse?> GetDidResponseForDid(string did)
public async Task<DidDocument?> GetDocumentForDid(string did)
{
return await memoryCache.GetOrCreateAsync(
$"did:{did}",
Expand All @@ -31,9 +31,8 @@ public class DidResolver(
async e =>
{
e.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
var didDocument = await GetDidResponseForDid(did);
return didDocument?.AlsoKnownAs[0]
.Replace("at://", "");
var didDocument = await GetDocumentForDid(did);
return didDocument?.GetHandle();
});
}

Expand All @@ -42,7 +41,7 @@ public class DidResolver(
/// </summary>
/// <param name="did">The DID.</param>
/// <returns>The response, if it exists.</returns>
private async Task<DidResponse?> ResolveDid(
private async Task<DidDocument?> ResolveDid(
string did)
{
Uri uri;
Expand Down Expand Up @@ -73,22 +72,22 @@ public class DidResolver(
/// </summary>
/// <param name="domain">The domain.</param>
/// <returns>The did response.</returns>
private async Task<DidResponse?> ResolveDidViaWeb(string domain)
private async Task<DidDocument?> ResolveDidViaWeb(string domain)
{
const string wellKnownUri = $"/.well-known/did.json";

using var client = clientFactory.CreateClient();
return await client.GetFromJsonAsync<DidResponse>($"https://{domain}{wellKnownUri}");
return await client.GetFromJsonAsync<DidDocument>($"https://{domain}{wellKnownUri}");
}

/// <summary>
/// Resolves a DID using the did:plc method.
/// </summary>
/// <param name="did">The DID.</param>
/// <returns>The did response.</returns>
private async Task<DidResponse?> ResolveDidViaPlcDirectory(string did)
private async Task<DidDocument?> ResolveDidViaPlcDirectory(string did)
{
using var client = clientFactory.CreateClient("did-resolver");
return await client.GetFromJsonAsync<DidResponse>($"/{did}");
return await client.GetFromJsonAsync<DidDocument>($"/{did}");
}
}
2 changes: 1 addition & 1 deletion PinkSea.AtProto/Resolvers/Did/IDidResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public interface IDidResolver
/// </summary>
/// <param name="did">The DID.</param>
/// <returns>The document, if it was possible to fetch.</returns>
Task<DidResponse?> GetDidResponseForDid(string did);
Task<DidDocument?> GetDocumentForDid(string did);

/// <summary>
/// Gets a handle from a DID.
Expand Down
4 changes: 2 additions & 2 deletions PinkSea.AtProto/Xrpc/Client/DPopXrpcClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,11 @@ public class DPopXrpcClient(
/// </summary>
/// <param name="obj">The object.</param>
/// <returns>The resulting query string.</returns>
private string ObjectToQueryParams(object obj)
private static string ObjectToQueryParams(object obj)
{
var props = from p in obj.GetType().GetProperties()
where p.GetValue(obj, null) != null
select p.Name.ToLowerInvariant() + "=" + HttpUtility.UrlEncode(p.GetValue(obj, null).ToString());
select p.Name.ToLowerInvariant() + "=" + HttpUtility.UrlEncode(p.GetValue(obj, null)!.ToString());

return string.Join('&', props.ToArray());
}
Expand Down
4 changes: 2 additions & 2 deletions PinkSea.AtProto/Xrpc/Client/SessionXrpcClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,11 @@ public void Dispose()
/// </summary>
/// <param name="obj">The object.</param>
/// <returns>The resulting query string.</returns>
private string ObjectToQueryParams(object obj)
private static string ObjectToQueryParams(object obj)
{
var props = from p in obj.GetType().GetProperties()
where p.GetValue(obj, null) != null
select p.Name.ToLowerInvariant() + "=" + HttpUtility.UrlEncode(p.GetValue(obj, null).ToString());
select p.Name.ToLowerInvariant() + "=" + HttpUtility.UrlEncode(p.GetValue(obj, null)!.ToString());

return string.Join('&', props.ToArray());
}
Expand Down
31 changes: 31 additions & 0 deletions PinkSea.Frontend/src/api/atproto/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Oekaki } from '@/models/oekaki'
import { usePersistedStore } from '@/state/store'

const uriRegex = /^at:\/\/did:[a-zA-Z0-9:.]+\/[a-zA-Z0-9.]+\/([a-zA-Z0-9]+)$/;

export const getRecordKeyFromAtUri = (uri: string) => {
const match = uri.match(uriRegex);
if (!match) {
return null;
} else {
return match[1];
}
}

export const buildOekakiUrlFromOekakiObject = (oekaki: Oekaki) => {
const rkey = getRecordKeyFromAtUri(oekaki.at)
return `/${oekaki.author.did}/oekaki/${rkey}`
}

export const formatDate = (date: Date) => {
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
day: 'numeric'
}

const persistedStore = usePersistedStore()

return new Date(date)
.toLocaleTimeString(persistedStore.lang ?? "en", options)
}
2 changes: 1 addition & 1 deletion PinkSea.Frontend/src/api/atproto/lexicons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ declare module '@atcute/client/lexicons' {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace ComShinolabsPinkseaGetParentForReply {
interface Params {
authorDid: string,
did: string,
rkey: string
}
}
Expand Down
6 changes: 3 additions & 3 deletions PinkSea.Frontend/src/components/RespondBox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ const uploadImage = async () => {
tags: [],
alt: alt.value,
nsfw: nsfw.value,
parent: props.parent.atProtoLink,
parent: props.parent.at,
bskyCrosspost: false
},
headers: {
Expand All @@ -83,14 +83,14 @@ const uploadImage = async () => {

imageStore.lastDoneReply = image.value;
imageStore.lastReplyErrored = true;
imageStore.lastReplyId = props.parent.oekakiRecordKey;
imageStore.lastReplyId = props.parent.at;

alert(i18next.t("painter.could_not_send_post"));
}
};

onBeforeMount(() => {
if (imageStore.lastReplyId == props.parent.oekakiRecordKey
if (imageStore.lastReplyId == props.parent.at
&& imageStore.lastDoneReply !== null && imageStore.lastReplyErrored)
{
image.value = imageStore.lastDoneReply;
Expand Down
10 changes: 8 additions & 2 deletions PinkSea.Frontend/src/components/TimeLine.vue
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,16 @@ import PostViewOekakiChildCard from '@/components/oekaki/PostViewOekakiChildCard

<template>
<Loader v-if="oekaki == null" />
<div class="timeline-container" v-else>
<span v-for="oekakiPost of oekaki" v-bind:key="oekakiPost.atProtoLink" v-bind="oekakiPost">
<div class="timeline-container" v-else-if="oekaki.length > 0">
<span v-for="oekakiPost of oekaki" v-bind:key="oekakiPost.at" v-bind="oekakiPost">
<TimeLineOekakiCard v-if="!props.showAsReplies" :oekaki="oekakiPost"/>
<PostViewOekakiChildCard v-else :oekaki="oekakiPost" :hide-line-bar="true" />
</span>
<Intersector @intersected="loadMore" />
</div>
<div class="timeline-container timeline-centered" v-else>
nothing here so far... (╥﹏╥)
</div>
</template>

<style scoped>
Expand All @@ -69,4 +72,7 @@ import PostViewOekakiChildCard from '@/components/oekaki/PostViewOekakiChildCard
.timeline-container div {
margin: 10px;
}
.timeline-centered {
text-align: center;
}
</style>
21 changes: 9 additions & 12 deletions PinkSea.Frontend/src/components/TimeLineOekakiCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Oekaki } from '@/models/oekaki'
import { useRouter } from 'vue-router'
import TagContainer from '@/components/TagContainer.vue'
import { usePersistedStore } from '@/state/store'
import { buildOekakiUrlFromOekakiObject, formatDate } from '@/api/atproto/helpers'

const router = useRouter();
const persistedStore = usePersistedStore();
Expand All @@ -12,25 +13,21 @@ const props = defineProps<{
oekaki: Oekaki
}>()

const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
day: 'numeric'
}

const imageLink = computed(() => `url(${props.oekaki.imageLink})`)
const authorProfileLink = computed(() => `/${props.oekaki.authorDid}`);
const imageLink = computed(() => `url(${props.oekaki.image})`)
const authorProfileLink = computed(() => `/${props.oekaki.author.did}`);
const creationTime = computed(() => {
return new Date(props.oekaki.creationTime).toLocaleTimeString(persistedStore.lang, options)
return formatDate(props.oekaki.creationTime)
})
const altText = computed(() => props.oekaki.alt ?? "");

const navigateToPost = () => {
router.push(`/${props.oekaki.authorDid}/oekaki/${props.oekaki.oekakiRecordKey}`);
const url = buildOekakiUrlFromOekakiObject(props.oekaki);
router.push(url);
};

const openInNewTab = () => {
window.open(`/${props.oekaki.authorDid}/oekaki/${props.oekaki.oekakiRecordKey}`, "_blank");
const url = buildOekakiUrlFromOekakiObject(props.oekaki);
window.open(url, "_blank");
};
</script>

Expand All @@ -40,7 +37,7 @@ const openInNewTab = () => {
<div class="oekaki-nsfw-blur" v-if="props.oekaki.nsfw && persistedStore.blurNsfw">NSFW</div>
</div>
<div class="oekaki-meta">
<span>{{ $t("timeline.by_before_handle") }}<b class="oekaki-author"> <RouterLink :to="authorProfileLink" >@{{ props.oekaki.authorHandle }}</RouterLink></b>{{ $t("timeline.by_after_handle") }}</span><br>
<span>{{ $t("timeline.by_before_handle") }}<b class="oekaki-author"> <RouterLink :to="authorProfileLink" >@{{ props.oekaki.author.handle }}</RouterLink></b>{{ $t("timeline.by_after_handle") }}</span><br>
<span>{{ creationTime }}</span><br>
<TagContainer v-if="props.oekaki.tags !== undefined && props.oekaki.tags.length > 0" :tags="props.oekaki.tags" />
<div class="oekaki-tag-container-substitute" v-else>.</div>
Expand Down
20 changes: 8 additions & 12 deletions PinkSea.Frontend/src/components/oekaki/PostViewOekakiChildCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import PostViewOekakiImageContainer from '@/components/oekaki/PostViewOekakiImag
import { usePersistedStore } from '@/state/store'
import { useRouter } from 'vue-router'
import { xrpc } from '@/api/atproto/client'
import { formatDate, getRecordKeyFromAtUri } from '@/api/atproto/helpers'

const props = defineProps<{
oekaki: Oekaki,
Expand All @@ -14,15 +15,9 @@ const props = defineProps<{
const router = useRouter();
const persistedStore = usePersistedStore();

const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
day: 'numeric'
}

const authorProfileLink = computed(() => `/${props.oekaki.authorDid}`);
const authorProfileLink = computed(() => `/${props.oekaki.author.did}`);
const creationTime = computed(() => {
return new Date(props.oekaki.creationTime).toLocaleTimeString(persistedStore.lang, options)
return formatDate(props.oekaki.creationTime)
})

const classList = computed(() => {
Expand All @@ -32,20 +27,21 @@ const classList = computed(() => {
})

const redirectToParent = async () => {
const rkey = getRecordKeyFromAtUri(props.oekaki.at);
const { data } = await xrpc.get("com.shinolabs.pinksea.getParentForReply", {
params: {
authorDid: props.oekaki.authorDid,
rkey: props.oekaki.oekakiRecordKey
did: props.oekaki.author.did,
rkey: rkey!
}
});

await router.push(`/${data.authorDid}/oekaki/${data.rkey}#${props.oekaki.authorDid}-${props.oekaki.oekakiRecordKey}`);
await router.push(`/${data.did}/oekaki/${data.rkey}#${props.oekaki.author.did}-${rkey}`);
};
</script>

<template>
<div :class="classList" v-if="!props.oekaki.nsfw || (props.oekaki.nsfw && !persistedStore.hideNsfw)">
<div class="oekaki-child-info">{{ $t("post.response_from_before_handle") }}<b class="oekaki-author"> <RouterLink :to="authorProfileLink" >@{{ props.oekaki.authorHandle }}</RouterLink></b>{{ $t("post.response_from_after_handle") }}{{ $t("post.response_from_at_date") }}{{ creationTime }}</div>
<div class="oekaki-child-info">{{ $t("post.response_from_before_handle") }}<b class="oekaki-author"> <RouterLink :to="authorProfileLink" >@{{ props.oekaki.author.handle }}</RouterLink></b>{{ $t("post.response_from_after_handle") }}{{ $t("post.response_from_at_date") }}{{ creationTime }}</div>
<PostViewOekakiImageContainer :oekaki="props.oekaki" v-on:click="redirectToParent" style="max-height: 400px; cursor: pointer;"/>
</div>
</template>
Expand Down
Loading
Loading